feat(sync): add Brex provider connections#1672
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds first-class Brex provider support: DB tables and models (BrexItem, BrexAccount), provider client and adapter, import/sync/processing flows, controllers and views for account linking/setup, encryption readiness centralization, Family integration, tests, and re-enables tracking of ChangesBrex Provider Integration
Sequence Diagram(s)sequenceDiagram
actor User
participant Browser as Browser
participant C as BrexItemsController / AccountFlowsController
participant AF as BrexItem::AccountFlow
participant P as Provider::Brex
participant DB as Database
Browser->>C: GET /brex_items/:id/select_accounts
C->>AF: preload_payload()
AF->>P: get_accounts()
P-->>AF: accounts payload
AF-->>C: JSON payload
C-->>Browser: render modal with accounts
Browser->>C: POST /brex_items/:id/link_accounts
C->>AF: link_new_accounts_result(params)
AF->>DB: upsert BrexAccount records
AF->>DB: create internal Accounts & AccountProviders
AF-->>C: navigation result
C-->>Browser: redirect to accounts
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes Possibly related PRs
Suggested labels
Suggested reviewers
✨ Finishing Touches🧪 Generate unit tests (beta)
|
5e9d426 to
dfc4ad6
Compare
sure-design
left a comment
There was a problem hiding this comment.
Early review notes for when this comes out of draft:
1. Fat controller (critical convention violation)
brex_items_controller.rb is 891 lines. The project convention is explicitly "Skinny Controllers, Fat Models." API fetching, cache management, account linking, flow orchestration — all of this belongs in model-layer objects (BrexItem, BrexItem::Importer, etc.), not the controller. Compare to Mercury: the controller there delegates to model concerns. This controller needs a significant extraction pass before it can land.
2. Conditional token encryption
if encryption_ready?
encrypts :token, deterministic: true
endIn a default self-hosted install without ACTIVE_RECORD_ENCRYPTION_* env vars, the API token is stored in plaintext. Other providers (Mercury, Coinbase) handle this the same way, but it's worth calling out explicitly here since Brex tokens are long-lived service credentials with broad account access. Consider at minimum a clear warning in the UI/docs when encryption is not configured.
3. Duplicated brex_account_name logic
The name-resolution logic appears three times: in BrexItemsController (private brex_account_name), in BrexItemsHelper (private brex_account_display_name), and in BrexAccount (private brex_account_name). One canonical place — the model — is the right home for this.
4. config/locales/common/en.yml is a new load path
This introduces a common/ subdirectory under config/locales/ that doesn't exist elsewhere. The single key it adds (en.common.close: Close) should go into the main en.yml or an existing shared locale file rather than creating a new directory.
5. begin/rescue wrapping entire action bodies is unnecessary
preload_accounts, select_accounts, etc. wrap their entire body in begin … rescue. Since the rescue is already at the top level of the method, the begin keyword is redundant and adds visual noise.
The security hardening (URL allowlist, safe_return_to_path, payload sanitisation, pagination cap) is solid. The PR description and test coverage are thorough. Main blocker before it leaves draft is the controller weight.
Generated by Claude Code
|
Thanks for implementing this. A few things worth flagging before this moves out of draft: Scope and maintainer buy-in: At 4,631 additions across 48 files, this is the largest open PR in the repo. Adding a new financial data provider (with API token storage, sync processors, account linking, and UI flows) is a significant architectural commitment. It would be worth confirming with @jjmata whether Brex is on the roadmap before investing further in review cycles. Relationship to existing providers: The PR mirrors Mercury's multi-connection pattern, which is the right approach. However, since this is an unsolicited provider addition from a first-time contributor, maintainers will want to verify that the credential handling (blank-token preservation, whitespace stripping, URL allowlist) matches the security bar set by existing providers. Trust flag: Same as your other PRs — automated trust scanner shows score 68/100. Maintainers will scrutinize credential storage and API token handling closely. Suggestion: Get explicit confirmation from maintainers that Brex integration is wanted before continuing to iterate on this PR. If it is, consider a slimmer initial PR scoped to just the provider client + importer (no UI), to make review tractable. Generated by Claude Code Generated by Claude Code |
|
Good to see Brex following the same patterns as Plaid/SimpleFIN. A few things:
Several flows (preload/select/link/setup) contain logic that belongs in
if encryption_ready?
encrypts :token, deterministic: true
endThis runs when the class is first loaded. If SSRF protection in The
Unrelated to Brex — if this is a leftover from development tooling it should either be in the global Generated by Claude Code |
Adds Brex item and account tables with per-family credentials, scoped upstream account uniqueness, encrypted token storage, and sanitized provider payload columns.
Adds Brex item/account models, provider client and adapter support, family connection helpers, and provider enum registration for read-only Brex cash and card data.
Adds Brex account discovery, linked-account sync, cash/card balance processors, transaction import, sanitized metadata handling, and idempotent provider entry processing.
Adds Mercury-style Brex connection management, explicit item-scoped account selection and linking, settings provider UI, account index visibility, localized copy, and per-item cache handling.
Adds targeted coverage for Brex provider requests, adapter config, item/account guards, importer behavior, entry processing, and Mercury-style controller flows.
Tightens Brex account fetching against the official card-account response shape, sends transaction start filters as RFC3339 date-times, and keeps provider error bodies out of user-facing messages while expanding provider client guard coverage.
Restrict Brex API base URLs to official hosts, tighten account-selection UI behavior, and add tests for invalid credentials, cache scoping, and provider setup edge cases.
04a9e9a to
c7ebd6d
Compare
Move remaining Brex review cleanup into focused model behavior, tighten link/setup edge cases, localize summaries, and add regression coverage from CodeRabbit feedback. Also records the security-review pass as no-findings after diff-scoped inspection and Brakeman validation.
Route Brex account selection and setup actions through small namespaced controllers while keeping existing URLs and helpers stable. Business flow remains in BrexItem::AccountFlow; the main Brex item controller now only handles connection CRUD, provider-panel rendering, destroy, and sync.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 40f924a6cf
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
|
|
||
| enum :status, { good: "good", requires_update: "requires_update" }, default: :good | ||
|
|
||
| encrypts :token, deterministic: true |
There was a problem hiding this comment.
Guard Brex token encryption behind readiness check
Make token encryption conditional here, otherwise Brex connections break in environments where Active Record encryption is intentionally not configured. This model calls encrypts :token unconditionally, but the rest of the codebase (and Encryptable comments/UI warning) supports a plaintext fallback when keys are absent; in that scenario creating/updating a BrexItem will raise encryption configuration errors instead of saving. Align this with other provider models by applying if encryption_ready? around the encrypts declaration.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
I don’t think we should make Brex conditional here. This was intentionally hardened differently from Mercury because Brex tokens are long-lived service credentials. The app now treats Brex token storage as fail-closed: self-hosted installs get runtime AR encryption keys from SECRET_KEY_BASE, partial env config raises at boot, and BrexItem.encryption_ready? checks runtime config as well as explicit keys.
Making this conditional would reintroduce plaintext Brex token storage, which this PR intentionally avoids. If maintainers prefer all providers to retain plaintext fallback for consistency, I can make that tradeoff explicitly, but my recommendation is to keep Brex fail-closed and add a clarifying comment/test if needed.
There was a problem hiding this comment.
Actionable comments posted: 9
🧹 Nitpick comments (19)
test/models/brex_entry/processor_test.rb (1)
25-34: ⚡ Quick winAssert the merchant payload is actually sanitized.
This fixture already includes
card_metadata.pan, but the test never proves that the savedextra["brex"]["merchant"]excludes it. Given the security-sensitive sanitization work in this PR, I'd lock that in here.🧪 Suggested assertion
assert_equal BigDecimal("12.34"), entry.amount assert_equal "USD", entry.currency assert_equal "brex", entry.source assert_equal Date.new(2026, 1, 2), entry.date assert_equal "STAPLES", entry.transaction.merchant.name assert_equal "card_1", entry.transaction.extra.dig("brex", "card_id") + refute_includes entry.transaction.extra.dig("brex", "merchant").to_s, "test-pan-placeholder"🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@test/models/brex_entry/processor_test.rb` around lines 25 - 34, The test "imports card purchase with Brex signed amount preserved" doesn’t assert that merchant data in the saved payload is sanitized; update this test (involving BrexEntry::Processor and the resulting entry variable) to explicitly check entry.transaction.extra.dig("brex","merchant") does not include card_metadata.pan (or any PAN field) and that expected merchant fields remain present, ensuring the saved extra["brex"]["merchant"] has sensitive PAN removed while other merchant attributes are preserved.app/models/brex_entry/processor.rb (1)
112-116: ⚡ Quick winAdd provider-specific invalid-currency logging.
parse_currencycan reject provider data here, but this processor never overrideslog_invalid_currency, so those warnings won't include the Brex transaction/account context that the other provider processors log.Based on learnings, standardize invalid-currency logging by overriding `log_invalid_currency` in each provider processor with provider-specific context.🪵 Suggested change
def currency parse_currency(BrexAccount.currency_code_from_money(data[:amount])) || parse_currency(brex_account.currency) || "USD" end + + def log_invalid_currency(currency_code) + Rails.logger.warn( + "BrexEntry::Processor - Invalid currency '#{currency_code}' for transaction #{data[:id]} on brex_account #{brex_account.id}" + ) + end🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/models/brex_entry/processor.rb` around lines 112 - 116, The currency resolution in BrexEntry::Processor (method currency) can trigger parse_currency rejections but this class never overrides log_invalid_currency, so those warnings lack Brex-specific context; add an override of log_invalid_currency in the BrexEntry::Processor class that formats and emits provider-specific details (e.g., include data[:id], data[:amount], BrexAccount.currency_from_money or brex_account.id/account identifier) and then calls or delegates to the shared logger behavior so invalid-currency warnings include Brex transaction/account context; reference the currency method, parse_currency call, BrexAccount.currency_code_from_money, and brex_account.currency when building the message.app/views/brex_items/_brex_item.html.erb (1)
87-102: ⚡ Quick winPrecompute sync/account data before rendering this partial.
This block does query/fallback work in ERB (
syncs.ordered.first, repeated association-backed checks/counts), which makes the partial responsible for data access and can add extra queries per Brex item on the accounts index. Pass the prepared stats/counts in, or wrap the lookup in a helper/component method so the template only renders values.As per coding guidelines "Avoid heavy logic in ERB views; prefer helpers and components" and "Keep domain logic out of views: compute values like button classes, conditional logic, and data transformations in the component file, not the template file".
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/views/brex_items/_brex_item.html.erb` around lines 87 - 102, The partial is performing data lookups (brex_item.syncs.ordered.first&.sync_stats and association-backed counts) which may trigger extra queries; move this logic out of the ERB by precomputing and passing in the values (stats, unlinked_count, linked_count, total_count, institutions_count) from the controller/presenter or by adding a helper method (e.g., fetch_brex_stats(brex_item, brex_sync_stats_map) or BrexItemPresenter methods) and update the call site that renders this partial to provide those locals instead of computing them inside the template; ensure ProviderSyncSummary still receives the stats param and replace uses of `@brex_sync_stats_map` and brex_item.*_accounts_count in the ERB with the passed-in locals.app/views/brex_items/select_accounts.html.erb (1)
11-12: ⚡ Quick winReplace raw
<form>withform_withto avoid manual CSRF token management.The manual
hidden_field_tag :authenticity_token, form_authenticity_tokenworks today, but it's easy to accidentally drop in a refactor.form_withhandles the CSRF token automatically and is idiomatic Rails.♻️ Proposed refactor
- <form action="<%= link_accounts_brex_items_path %>" method="post" class="space-y-4" data-turbo-frame="_top"> - <%= hidden_field_tag :authenticity_token, form_authenticity_token %> + <%= form_with url: link_accounts_brex_items_path, method: :post, class: "space-y-4", data: { turbo_frame: "_top" } do |_form| %>- </form> + <% end %>🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/views/brex_items/select_accounts.html.erb` around lines 11 - 12, Replace the raw <form> and manual CSRF hidden_field_tag with Rails' form_with helper: call form_with url: link_accounts_brex_items_path, method: :post and pass the html options (class: "space-y-4", data: { turbo_frame: "_top" }) so the form uses the same action and attributes; remove the hidden_field_tag :authenticity_token, form_authenticity_token because form_with will automatically include the CSRF token. Ensure you update the closing tag/ERB block that currently wraps the form contents to use form_with's block form.app/models/brex_item/account_flow.rb (5)
622-627: 💤 Low valueHard-coded subtype strings — surface them as constants near the consuming model.
"credit_card"and"checking"are subtype identifiers tied toCreditCard::SUBTYPESandDepository::SUBTYPESrespectively. Hard-coding them here makes a future rename in those constants silently break Brex setup defaults. Consider referencing a constant on the accountable (e.g.CreditCard::DEFAULT_SUBTYPE/Depository::DEFAULT_SUBTYPE) so renames fail loudly at boot.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/models/brex_item/account_flow.rb` around lines 622 - 627, The method selected_subtype_for in account_flow.rb currently returns hard-coded strings "credit_card" and "checking"; replace those literals with constants on the related models (e.g. CreditCard::DEFAULT_SUBTYPE and Depository::DEFAULT_SUBTYPE) so renames fail loudly. Update selected_subtype_for to reference those constants when selected_type == "CreditCard" and selected_type == "Depository", and add/ensure the constants (DEFAULT_SUBTYPE or similarly named) exist on CreditCard and Depository to point to the current default values.
88-185: ⚖️ Poor tradeoffSignificant duplication between
select_accounts_resultandselect_existing_account_result.The two methods share substantial structure: both call
selection_failure_result, both filter accounts (with slightly different empty messages), both buildSelectionResultfor each branch, and both have nearly identicalrescuechains forNoApiTokenError/Provider::Brex::BrexError/StandardErrordiffering only in the i18n scope. Extracting a private helper that takes a config hash (scope_key,accountable_type, success/empty messages) would remove ~70 lines of repetition and make future status additions a one-place change.♻️ Sketch
def selection_result_for(scope:, accountable_type:, empty_message_key:) return selection_failure_result(scope, accountable_type: accountable_type) unless selected? accounts = filter_accounts(unlinked_available_accounts, accountable_type) return selection_result(:empty, accountable_type, I18n.t("#{scope}.#{empty_message_key}")) if accounts.empty? selection_result(:success, accountable_type, nil, available_accounts: accounts) rescue NoApiTokenError selection_result(:no_api_token, accountable_type, I18n.t("#{scope}.no_api_token")) rescue Provider::Brex::BrexError => e Rails.logger.error("Brex API error in #{scope}: #{e.message}") selection_result(:api_error, accountable_type, e.message) rescue StandardError => e Rails.logger.error("Unexpected error in #{scope}: #{e.class}: #{e.message}") selection_result(:unexpected_error, accountable_type, I18n.t("#{scope}.unexpected_error")) endAs per coding guidelines: avoid violations of DRY in changed code.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/models/brex_item/account_flow.rb` around lines 88 - 185, The two methods select_accounts_result and select_existing_account_result are nearly identical; extract a private helper (e.g., selection_result_for or selection_result_with_scope) that accepts parameters like scope_key (string/symbol for i18n keys and logging), accountable_type, and an empty_message_key, centralizes the filter_accounts/unlinked_available_accounts logic, builds SelectionResult via a small selection_result(...) helper, and consolidates the rescue handlers (NoApiTokenError, Provider::Brex::BrexError, StandardError) so both original methods simply call this helper with their scope and message keys; update select_accounts_result and select_existing_account_result to delegate to that helper and remove the duplicated blocks.
286-301: 💤 Low value
import_accounts_from_api_if_neededwill not refresh accounts after the initial import.The early return
return nil unless brex_item.brex_accounts.empty?means once any account has been persisted, this method becomes a no-op — even if the user added new accounts on the Brex side and reopened the setup modal. As a result,BrexItems::AccountSetupsController#setup_accountsnever picks up newly-available accounts in the setup UI; users would need to wait for the next background sync. If that's intentional, a comment would help; otherwise consider re-fetching when the cache is also empty/stale, or distinguish "no accounts ever" from "no new accounts since last sync".🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/models/brex_item/account_flow.rb` around lines 286 - 301, The current guard in import_accounts_from_api_if_needed (return nil unless brex_item.brex_accounts.empty?) prevents any subsequent refresh once a single account exists; change the logic to always fetch available_accounts (after still raising NoApiTokenError if credentials aren't configured), return nil only if fetched accounts are empty, then upsert only missing or updated accounts by comparing fetched account ids to existing brex_item.brex_accounts (e.g., existing_ids = brex_item.brex_accounts.pluck(:external_id)) and calling upsert_brex_account! for accounts whose id is not present or whose attributes differ; keep the NoApiTokenError and preserve final nil return.
67-86: 💤 Low value
preload_payloadignores cached value's emptiness for write-skipping but recomputes regardless on cache miss.Two small concerns:
- The cached array short-circuit at line 71-72 treats any cached value (including
[]) as "valid cache" viaunless cached_accounts.nil?, which is correct, but the booleanhas_accountsreturned forcached: trueis computed from the cached array. Make sure the writer inaccounts(private) and any cache invalidation paths never store a stale[]as if it were a real result; otherwise users will see "no accounts" until the 5-minute TTL expires.preload_payloadwrites the cache (line 75) and thenaccounts(line 570) re-reads + writes again. They sharecache_keyso it's fine in steady state, but a request that callspreload_payloadfollowed byselect_accounts_resultwill perform two cache reads where one would do.Optional cleanup: have
preload_payloaddelegate toaccountsrather than duplicate the read/fetch/write pattern.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/models/brex_item/account_flow.rb` around lines 67 - 86, preload_payload currently duplicates cache read/fetch/write logic and can cause double cache reads and stale empty-array behavior; change preload_payload to delegate to the existing private accounts method (call accounts or accounts(force: false) as appropriate) instead of re-implementing read/fetch/write, so it reuses the single authoritative cache_key/CACHE_TTL behavior and avoids a second read/write; also ensure the accounts writer only writes results when the fetch succeeds (so it does not persist a stale []), and keep cached presence checks driven by Rails.cache.exist?(cache_key) or the accounts method return value rather than testing for nil directly.
358-411: ⚖️ Poor tradeoff
complete_setup!holds transaction locks across multiple account creation iterations unnecessarily.With
skip_initial_sync: trueon eachAccount.create_and_synccall, no background jobs are enqueued during account creation—only database operations (save, opening balance, account shares) execute within the transaction. However, the entire loop runs inside a singleActiveRecord::Base.transaction, meaning all created accounts remain locked for the duration of all iterations. If any account creation fails late in the loop, every prior account is rolled back.Consider per-account transactions so a single bad account doesn't undo all prior successful setups, unless atomic-all-or-none behavior is strictly required for your use case.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/models/brex_item/account_flow.rb` around lines 358 - 411, The current complete_setup! wraps the entire account_types loop in one ActiveRecord::Base.transaction which holds locks across all account creations; change this to use a per-account transaction around each create path (wrap the Account.create_and_sync and AccountProvider.create! for each brex_account in its own transaction) so a failure creating one account only rolls back that account instead of all prior ones, while leaving brex_item.sync_later and the final SetupResult behavior unchanged; alternatively, if you truly need atomic all-or-none semantics, document that and keep the outer transaction.app/models/family/brex_connectable.rb (1)
1-29: 💤 Low valueLGTM — concern follows the existing
*Connectablepattern.Symmetrical with other provider concerns:
has_many :brex_items, dependent: :destroy,create_*_item!followed bysync_later, and a credentials predicate. Triggeringsync_lateraftercreate!keeps the object-creation contract clean.One small note on
has_brex_credentials?:brex_items.active.any?(&:credentials_configured?)loads all active items into memory and iterates in Ruby. For most families this is fine, but ifcredentials_configured?is expressible as a SQL-side condition (e.g.where.not(token: [nil, ""])), pushing it into a scope (brex_items.active.with_credentials.exists?) avoids the N-row materialization. Optional.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/models/family/brex_connectable.rb` around lines 1 - 29, The has_brex_credentials? method currently materializes all active BrexItem records and calls credentials_configured? in Ruby; instead add a DB-side scope (e.g. in the BrexItem model define scope :with_credentials, -> { where.not(token: [nil, ""]) } or whichever SQL condition matches credentials_configured?) and change Family::BrexConnectable#has_brex_credentials? to use brex_items.active.with_credentials.exists? so the check runs in SQL and avoids loading all rows.app/models/brex_item/importer.rb (3)
164-179: 🏗️ Heavy lift
store_new_transactionsrewrites the entire transactions JSONB on every page — verify with bounded sync windows.
upsert_brex_transactions_snapshot!(existing_transactions + new_transactions)rewrites the full payload on each sync. WithProvider::Brexpagination caps (MAX_PAGES=250,DEFAULT_LIMIT=1000) raised in PR feedback, this can store hundreds of thousands of transactions in a single JSONB column, growing unbounded per account. Each subsequent sync deserializes/serializes the entire history, allocates awith_indifferent_accesswrapper per existing entry for dedup, and writes the union back.Two complementary improvements:
- Bound the appended history to a rolling window (e.g., last N days or last K rows) so the JSONB doesn't grow unboundedly.
- Pass
sync.window_start_date(per jjmata's review feedback) intodetermine_sync_start_dateso we don't re-fetch and re-merge the full history each time.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/models/brex_item/importer.rb` around lines 164 - 179, store_new_transactions currently reads the full brex_account.raw_transactions_payload, dedups against all IDs, then calls upsert_brex_transactions_snapshot! with the union—this rewrites an ever-growing JSONB each page; instead, add a window_start_date parameter (pass sync.window_start_date into determine_sync_start_date and thread it into store_new_transactions) and only keep/merge transactions within that rolling window (e.g., last N days or last K rows) before deduping and upserting; prune existing_transactions to the window, select new_transactions whose timestamps are >= window_start_date, and call upsert_brex_transactions_snapshot! with the pruned union so we no longer deserialize/serialize the entire history on every sync.
56-60: 💤 Low valueSnapshot persistence failure is silently swallowed.
store_item_snapshotrescues all errors and only logs. The import continues to callimport_accounts/import_transactionseven though the institution/raw_payload snapshot wasn't stored — meaning views that rely onraw_payload/raw_institution_payloadmay be stale or empty without any visible signal to the user (brex_item.statusstays whatever it was). Consider re-raising for non-transient failures, or at least reporting via Sentry likeBrexAccount::Processordoes.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/models/brex_item/importer.rb` around lines 56 - 60, The current store_item_snapshot method swallows all exceptions from brex_item.upsert_brex_snapshot! which allows the import to continue with missing raw_payload; change store_item_snapshot (and its rescue) so that on error you both report the exception to the error tracker (e.g. Sentry.capture_exception or the same mechanism used by BrexAccount::Processor) including context (brex_item id, accounts_data) and then re-raise the exception (or set a durable failure state on brex_item such as updating brex_item.status to a snapshot failure state before re-raising) so the overall import halts and the failure is visible.
23-23: 💤 Low valueStatus reset never marks healthy items back to
:goodafter partial failure recovery — but conversely, a non-credentials transient error keeps the item in whatever state it was previously in.
brex_item.update!(status: :good)only fires when the entire import has zero failures. After a transient API error causedmark_requires_update_if_credentials_errorto set:requires_updateonce (on a 401/403), a subsequent successful sync will move it back to:good. That's correct. However, if the prior failure was a non-credentials error that didn't change status, and the current sync has any failures (e.g., one transient transaction failure), the item may stay in a stale status indefinitely. Consider whether the success/health flag should reflect the latest sync regardless of prior state, or split status into "credentials valid" vs. "last sync ok".🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/models/brex_item/importer.rb` at line 23, The current logic lets prior non-credentials failures leave brex_item in a stale non-:good state; ensure the item’s status reflects the latest sync by unconditionally setting status to :good when the current import had zero account and zero transaction failures (i.e., when account_result[:accounts_failed].zero? && transaction_result[:transactions_failed].zero?), and make sure mark_requires_update_if_credentials_error only sets :requires_update on 401/403 without blocking later status updates — move or adjust the brex_item.update!(status: :good) call and any early returns so the status reset runs based on the current results regardless of previous state.app/models/brex_account/transactions/processor.rb (2)
44-51: 💤 Low valueTrim full backtrace logging to first N frames for consistency.
Rails.logger.error Array(e.backtrace).join("\n")writes the full backtrace (often dozens of frames) for every failed transaction. With a malformed payload, this can flood logs. Other Brex code (e.g.BrexAccount::Processor#process_transactionsandBrexItem::AccountFlow#complete_setup_result) usesArray(e.backtrace).first(10). Consider matching that limit here.♻️ Proposed change
- Rails.logger.error Array(e.backtrace).join("\n") + Rails.logger.error Array(e.backtrace).first(10).join("\n")🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/models/brex_account/transactions/processor.rb` around lines 44 - 51, The rescue block currently logs the full exception backtrace via Rails.logger.error Array(e.backtrace).join("\n"), which can flood logs; update the error logging in the rescue (the code around failed_count, transaction_id_for(transaction_data), and errors << ...) to instead log only the first N frames (match other Brex code by using Array(e.backtrace).first(10)) and join those frames for the Rails.logger.error call so the rest of the rescue behavior (incrementing failed_count and appending to errors) remains unchanged.
30-33: 💤 Low valueConsider a more specific signal for "skipped" vs. "failed" transactions.
A
nilreturn fromBrexEntry::Processoris currently bucketed intofailed_countwith the hard-coded reason"No linked account". If the processor ever returnsnilfor a different valid skip reason, both the count and the error message would become misleading. Returning a dedicated sentinel (e.g. a symbol:skipped) or splittingfailed_countfromskipped_countwould make the result safer for future evolution.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/models/brex_account/transactions/processor.rb` around lines 30 - 33, The current logic treats any nil result from BrexEntry::Processor as a failure with message "No linked account"; change this to a clear distinction by having BrexEntry::Processor return a sentinel (e.g. :skipped) for intentional skips and update the caller (the code handling result in processor.rb) to branch: if result == :skipped increment a new skipped_count and record a skip reason (or omit error), if result.nil? keep incrementing failed_count and record the real failure message (or transaction_id_for(transaction_data)); update any aggregated reporting to include skipped_count. Ensure you reference BrexEntry::Processor and the result handling block in processor.rb when making these changes.db/migrate/20260505010000_create_brex_items_and_accounts.rb (1)
32-57: ⚡ Quick winConsider tightening currency null constraint at the DB layer.
BrexAccountvalidatescurrencypresence in the model, but the column is nullable here. As per coding guidelines, simple validations (null checks, unique indexes) should be enforced in the database schema for PostgreSQL. Aligning the schema with the model invariant prevents bypass through direct SQL or future code paths.♻️ Proposed change
- t.string :currency + t.string :currency, null: falseYou may want to also add a default (e.g.
"USD") to make the migration safe to run on existing data, and align withBrexAccount.currency_code_from_moneydefaults used by the importer.As per coding guidelines: "Use simple database validations (null checks, unique indexes) in database schema".
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@db/migrate/20260505010000_create_brex_items_and_accounts.rb` around lines 32 - 57, The brex_accounts table currently allows NULL for the currency column but the BrexAccount model requires presence; update the migration to enforce this at DB level by changing the currency column to null: false and add a sensible default (e.g. "USD") to make the migration safe for existing rows (use t.string :currency, null: false, default: "USD"); ensure this aligns with BrexAccount.currency_code_from_money and any importer logic so defaults are consistent.app/models/brex_account/processor.rb (1)
38-41: 💤 Low valueConsider raising or alerting on persistent currency parse failures rather than silently defaulting to USD.
Defaulting to
"USD"whenparse_currencyfails keeps sync from breaking, but the warn log alone is easy to miss. If a non-USD account is silently converted to USD, downstream balance reporting will be subtly wrong. Consider a Sentry breadcrumb/capture_messagehere so it surfaces in monitoring without aborting the sync.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/models/brex_account/processor.rb` around lines 38 - 41, In BrexAccount::Processor, inside the branch where currency is nil after parse (the block referencing currency and brex_account.id), add monitoring so failures are visible: call Sentry.capture_message (or Sentry.add_breadcrumb + capture_message) with a clear message like "BrexAccount currency parse failed", include context/tags for brex_account.id and the raw brex_account.currency value, and set level to warning/error; keep the current fallback to "USD" so sync continues but ensure the capture includes useful context for debugging.app/controllers/brex_items/account_setups_controller.rb (1)
15-33: 💤 Low valueConsider strong params for
account_types/account_subtypes.
params[:account_types]andparams[:account_subtypes]are passed straight through as raw hashes. WhileBrexItem::AccountFlow#complete_setup!does whitelistselected_typeagainstProvider::BrexAdapter.supported_account_types, treating hash params asActionController::Parametersand explicitly permitting them keeps behavior consistent with the rest of the controller layer and avoids surprises if these params are used differently in future.As per coding guidelines: "Implement strong parameters and CSRF protection throughout the application".
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/controllers/brex_items/account_setups_controller.rb` around lines 15 - 33, The controller currently passes raw hashes from params[:account_types] and params[:account_subtypes] into brex_account_flow.complete_setup_result; update complete_account_setup to sanitize these by using strong params: build sanitized_account_types = params.fetch(:account_types, {}).permit(allowed_types) and sanitized_account_subtypes = params.fetch(:account_subtypes, {}).permit(allowed_subtypes), where allowed_types = Provider::BrexAdapter.supported_account_types.map(&:to_s) (and use the equivalent supported list for subtypes or an explicit whitelist), then pass those sanitized vars into brex_account_flow.complete_setup_result instead of the raw params to ensure only expected keys are forwarded.app/models/brex_item.rb (1)
33-44: ⚡ Quick winAvoid raising bare
StandardError.
raise StandardError.new("Brex provider is not configured")forces every caller to rescue the universal base class to catch this specific condition, which collides with unrelated failures. Define a domain-specific error (or reuseProvider::Brex::BrexError) so the importer/sync layers can branch on it deterministically.♻️ Proposed change
+ class ProviderNotConfiguredError < StandardError; end + def import_latest_brex_data(sync_start_date: nil) provider = brex_provider unless provider Rails.logger.error "BrexItem #{id} - Cannot import: provider is not configured" - raise StandardError.new("Brex provider is not configured") + raise ProviderNotConfiguredError, "Brex provider is not configured" end🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/models/brex_item.rb` around lines 33 - 44, The method import_latest_brex_data currently raises a bare StandardError when the provider is missing; change this to raise a domain-specific error (e.g., Provider::Brex::BrexError or a new BrexProviderNotConfiguredError) so callers can rescue the specific condition; keep the existing Rails.logger.error line, and raise the chosen domain error from import_latest_brex_data (and ensure any existing rescue blocks that expect provider-specific errors use the new error type, e.g., in BrexItem::Importer and calling sync/import layers).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@app/controllers/brex_items/account_flows_controller.rb`:
- Around line 98-111: The safe_return_to_path method allows paths like
"/\evil.com" because URI.parse normalizes backslashes; update
safe_return_to_path to additionally reject any return_to where the second
character (after the leading "/") is "\" or "/" or is a control/whitespace
character, and also strip/trust no surrounding whitespace — i.e., after
converting params[:return_to] to a string, ensure it starts with "/" and that
return_to[1] is present and is not "\\" or "/" and not a control/whitespace
codepoint before returning; keep the existing URI.parse and rescue
URI::InvalidURIError behavior but perform these extra validations (referencing
safe_return_to_path and params[:return_to]).
In `@app/models/brex_account.rb`:
- Around line 8-11: BrexAccount currently wraps encrypts :raw_payload and
:raw_transactions_payload with if encryption_ready? while BrexItem declares
encrypts :token unconditionally; make the policy consistent by removing the
conditional in BrexAccount and declaring encrypts :raw_payload and encrypts
:raw_transactions_payload unconditionally (matching BrexItem's approach), delete
or stop using the class-level encryption_ready? guard in this model, and ensure
the existing boot-time encryption health check (the one referenced in other PR
feedback) runs early and fails fast so missing keys are detected at boot rather
than leaving models partially unencrypted.
In `@app/models/brex_item.rb`:
- Around line 46-61: The loop in BrexItem#process_accounts causes an N+1 because
BrexAccount::Processor#process repeatedly accesses brex_account.current_account
(a delegation to the account association) but the query only uses
brex_accounts.joins(:account).merge(Account.visible) for filtering and does not
preload the association; update the query used in process_accounts (the
brex_accounts.joins(:account).merge(Account.visible).each call) to preload the
account association (e.g., add includes(:account) alongside the joins) so
current_account is eager loaded before iteration and prevents per-iteration
queries when BrexAccount::Processor#process reads or updates account fields.
In `@app/models/brex_item/importer.rb`:
- Around line 67-71: The pluck call in linked_account_ids is ambiguous because
the join to account_providers makes account_id refer to multiple tables; update
the pluck to explicitly reference the brex_accounts table name (use
"#{BrexAccount.table_name}.account_id") when building linked_account_ids (and
mirror the same explicit pluck used for all_existing_ids if applicable) so
PostgreSQL resolves the correct brex_accounts.account_id column; locate the
calls on brex_item.brex_accounts (variables linked_account_ids and
all_existing_ids) and replace the ambiguous pluck with the fully qualified
column reference.
In `@app/models/provider/brex.rb`:
- Around line 60-68: get_card_accounts currently calls get_json and
extract_records once, which can truncate large result sets; change it to use the
same paginated helper as get_cash_accounts (call
get_paginated("/v2/accounts/card")) so results honor MAX_PAGES/DEFAULT_LIMIT and
cursor handling, then map each account with
with_indifferent_access.merge(account_kind: "card") just like get_cash_accounts;
ensure you remove the single-page extract_records usage and rely on
get_paginated to return the full bounded list.
In `@app/views/settings/providers/_brex_panel.html.erb`:
- Around line 63-68: The delete button rendered by the button_to call using
brex_item_path currently only shows icon "trash-2" and lacks an accessible
label; update the button_to in _brex_panel.html.erb to include an aria-label (or
title) using a new I18n key (e.g., brex_items.provider_panel.disconnect_label)
that interpolates the item.name (e.g., "Disconnect %{name}"), add that key to
the Brex items locale file, and ensure the button_to passes aria-label:
t("brex_items.provider_panel.disconnect_label", name: item.name) so screen
readers receive a meaningful label while preserving the existing data: {
turbo_confirm: ... } and icon usage.
In `@app/views/settings/providers/show.html.erb`:
- Around line 64-68: Replace the hard-coded title "Brex (beta)" passed to the
settings_section helper with a translation call (use t(...)) and add the
corresponding key to config/locales/en.yml; locate the usage in the view where
settings_section is called (the title: argument in
app/views/settings/providers/show.html.erb) and replace it with a descriptive
i18n key (e.g., providers.brex.title or settings.providers.brex.title), then add
that key and value "Brex (beta)" to en.yml so the view renders the localized
string.
In `@config/locales/defaults/en.yml`:
- Around line 6-7: The translation key "close" is currently defined under common
(en.common.close) but the app expects defaults.common.close; update the YAML
structure so that common is nested under defaults (i.e., move the common block
inside a defaults mapping) so the key becomes defaults.common.close, ensuring
the "close" string is reachable where code looks it up.
In `@test/controllers/brex_items_controller_test.rb`:
- Around line 6-23: The tests currently call Rails.cache.clear in setup and
teardown which wipes global cache; instead only remove the Brex-related cache
entries: in the setup/teardown for this test file replace the global clear with
targeted deletion of keys created by these tests (use the same key patterns or
helper used by BrexItem or family caching—e.g. keys derived from
BrexItem.cache_key or a "brex" prefix) so that code in setup/teardown deletes
only keys for BrexItem/@existing_item/@second_item (or use
Rails.cache.delete_matched with the Brex key pattern) rather than calling
Rails.cache.clear.
---
Nitpick comments:
In `@app/controllers/brex_items/account_setups_controller.rb`:
- Around line 15-33: The controller currently passes raw hashes from
params[:account_types] and params[:account_subtypes] into
brex_account_flow.complete_setup_result; update complete_account_setup to
sanitize these by using strong params: build sanitized_account_types =
params.fetch(:account_types, {}).permit(allowed_types) and
sanitized_account_subtypes = params.fetch(:account_subtypes,
{}).permit(allowed_subtypes), where allowed_types =
Provider::BrexAdapter.supported_account_types.map(&:to_s) (and use the
equivalent supported list for subtypes or an explicit whitelist), then pass
those sanitized vars into brex_account_flow.complete_setup_result instead of the
raw params to ensure only expected keys are forwarded.
In `@app/models/brex_account/processor.rb`:
- Around line 38-41: In BrexAccount::Processor, inside the branch where currency
is nil after parse (the block referencing currency and brex_account.id), add
monitoring so failures are visible: call Sentry.capture_message (or
Sentry.add_breadcrumb + capture_message) with a clear message like "BrexAccount
currency parse failed", include context/tags for brex_account.id and the raw
brex_account.currency value, and set level to warning/error; keep the current
fallback to "USD" so sync continues but ensure the capture includes useful
context for debugging.
In `@app/models/brex_account/transactions/processor.rb`:
- Around line 44-51: The rescue block currently logs the full exception
backtrace via Rails.logger.error Array(e.backtrace).join("\n"), which can flood
logs; update the error logging in the rescue (the code around failed_count,
transaction_id_for(transaction_data), and errors << ...) to instead log only the
first N frames (match other Brex code by using Array(e.backtrace).first(10)) and
join those frames for the Rails.logger.error call so the rest of the rescue
behavior (incrementing failed_count and appending to errors) remains unchanged.
- Around line 30-33: The current logic treats any nil result from
BrexEntry::Processor as a failure with message "No linked account"; change this
to a clear distinction by having BrexEntry::Processor return a sentinel (e.g.
:skipped) for intentional skips and update the caller (the code handling result
in processor.rb) to branch: if result == :skipped increment a new skipped_count
and record a skip reason (or omit error), if result.nil? keep incrementing
failed_count and record the real failure message (or
transaction_id_for(transaction_data)); update any aggregated reporting to
include skipped_count. Ensure you reference BrexEntry::Processor and the result
handling block in processor.rb when making these changes.
In `@app/models/brex_entry/processor.rb`:
- Around line 112-116: The currency resolution in BrexEntry::Processor (method
currency) can trigger parse_currency rejections but this class never overrides
log_invalid_currency, so those warnings lack Brex-specific context; add an
override of log_invalid_currency in the BrexEntry::Processor class that formats
and emits provider-specific details (e.g., include data[:id], data[:amount],
BrexAccount.currency_from_money or brex_account.id/account identifier) and then
calls or delegates to the shared logger behavior so invalid-currency warnings
include Brex transaction/account context; reference the currency method,
parse_currency call, BrexAccount.currency_code_from_money, and
brex_account.currency when building the message.
In `@app/models/brex_item.rb`:
- Around line 33-44: The method import_latest_brex_data currently raises a bare
StandardError when the provider is missing; change this to raise a
domain-specific error (e.g., Provider::Brex::BrexError or a new
BrexProviderNotConfiguredError) so callers can rescue the specific condition;
keep the existing Rails.logger.error line, and raise the chosen domain error
from import_latest_brex_data (and ensure any existing rescue blocks that expect
provider-specific errors use the new error type, e.g., in BrexItem::Importer and
calling sync/import layers).
In `@app/models/brex_item/account_flow.rb`:
- Around line 622-627: The method selected_subtype_for in account_flow.rb
currently returns hard-coded strings "credit_card" and "checking"; replace those
literals with constants on the related models (e.g. CreditCard::DEFAULT_SUBTYPE
and Depository::DEFAULT_SUBTYPE) so renames fail loudly. Update
selected_subtype_for to reference those constants when selected_type ==
"CreditCard" and selected_type == "Depository", and add/ensure the constants
(DEFAULT_SUBTYPE or similarly named) exist on CreditCard and Depository to point
to the current default values.
- Around line 88-185: The two methods select_accounts_result and
select_existing_account_result are nearly identical; extract a private helper
(e.g., selection_result_for or selection_result_with_scope) that accepts
parameters like scope_key (string/symbol for i18n keys and logging),
accountable_type, and an empty_message_key, centralizes the
filter_accounts/unlinked_available_accounts logic, builds SelectionResult via a
small selection_result(...) helper, and consolidates the rescue handlers
(NoApiTokenError, Provider::Brex::BrexError, StandardError) so both original
methods simply call this helper with their scope and message keys; update
select_accounts_result and select_existing_account_result to delegate to that
helper and remove the duplicated blocks.
- Around line 286-301: The current guard in import_accounts_from_api_if_needed
(return nil unless brex_item.brex_accounts.empty?) prevents any subsequent
refresh once a single account exists; change the logic to always fetch
available_accounts (after still raising NoApiTokenError if credentials aren't
configured), return nil only if fetched accounts are empty, then upsert only
missing or updated accounts by comparing fetched account ids to existing
brex_item.brex_accounts (e.g., existing_ids =
brex_item.brex_accounts.pluck(:external_id)) and calling upsert_brex_account!
for accounts whose id is not present or whose attributes differ; keep the
NoApiTokenError and preserve final nil return.
- Around line 67-86: preload_payload currently duplicates cache read/fetch/write
logic and can cause double cache reads and stale empty-array behavior; change
preload_payload to delegate to the existing private accounts method (call
accounts or accounts(force: false) as appropriate) instead of re-implementing
read/fetch/write, so it reuses the single authoritative cache_key/CACHE_TTL
behavior and avoids a second read/write; also ensure the accounts writer only
writes results when the fetch succeeds (so it does not persist a stale []), and
keep cached presence checks driven by Rails.cache.exist?(cache_key) or the
accounts method return value rather than testing for nil directly.
- Around line 358-411: The current complete_setup! wraps the entire
account_types loop in one ActiveRecord::Base.transaction which holds locks
across all account creations; change this to use a per-account transaction
around each create path (wrap the Account.create_and_sync and
AccountProvider.create! for each brex_account in its own transaction) so a
failure creating one account only rolls back that account instead of all prior
ones, while leaving brex_item.sync_later and the final SetupResult behavior
unchanged; alternatively, if you truly need atomic all-or-none semantics,
document that and keep the outer transaction.
In `@app/models/brex_item/importer.rb`:
- Around line 164-179: store_new_transactions currently reads the full
brex_account.raw_transactions_payload, dedups against all IDs, then calls
upsert_brex_transactions_snapshot! with the union—this rewrites an ever-growing
JSONB each page; instead, add a window_start_date parameter (pass
sync.window_start_date into determine_sync_start_date and thread it into
store_new_transactions) and only keep/merge transactions within that rolling
window (e.g., last N days or last K rows) before deduping and upserting; prune
existing_transactions to the window, select new_transactions whose timestamps
are >= window_start_date, and call upsert_brex_transactions_snapshot! with the
pruned union so we no longer deserialize/serialize the entire history on every
sync.
- Around line 56-60: The current store_item_snapshot method swallows all
exceptions from brex_item.upsert_brex_snapshot! which allows the import to
continue with missing raw_payload; change store_item_snapshot (and its rescue)
so that on error you both report the exception to the error tracker (e.g.
Sentry.capture_exception or the same mechanism used by BrexAccount::Processor)
including context (brex_item id, accounts_data) and then re-raise the exception
(or set a durable failure state on brex_item such as updating brex_item.status
to a snapshot failure state before re-raising) so the overall import halts and
the failure is visible.
- Line 23: The current logic lets prior non-credentials failures leave brex_item
in a stale non-:good state; ensure the item’s status reflects the latest sync by
unconditionally setting status to :good when the current import had zero account
and zero transaction failures (i.e., when account_result[:accounts_failed].zero?
&& transaction_result[:transactions_failed].zero?), and make sure
mark_requires_update_if_credentials_error only sets :requires_update on 401/403
without blocking later status updates — move or adjust the
brex_item.update!(status: :good) call and any early returns so the status reset
runs based on the current results regardless of previous state.
In `@app/models/family/brex_connectable.rb`:
- Around line 1-29: The has_brex_credentials? method currently materializes all
active BrexItem records and calls credentials_configured? in Ruby; instead add a
DB-side scope (e.g. in the BrexItem model define scope :with_credentials, -> {
where.not(token: [nil, ""]) } or whichever SQL condition matches
credentials_configured?) and change
Family::BrexConnectable#has_brex_credentials? to use
brex_items.active.with_credentials.exists? so the check runs in SQL and avoids
loading all rows.
In `@app/views/brex_items/_brex_item.html.erb`:
- Around line 87-102: The partial is performing data lookups
(brex_item.syncs.ordered.first&.sync_stats and association-backed counts) which
may trigger extra queries; move this logic out of the ERB by precomputing and
passing in the values (stats, unlinked_count, linked_count, total_count,
institutions_count) from the controller/presenter or by adding a helper method
(e.g., fetch_brex_stats(brex_item, brex_sync_stats_map) or BrexItemPresenter
methods) and update the call site that renders this partial to provide those
locals instead of computing them inside the template; ensure ProviderSyncSummary
still receives the stats param and replace uses of `@brex_sync_stats_map` and
brex_item.*_accounts_count in the ERB with the passed-in locals.
In `@app/views/brex_items/select_accounts.html.erb`:
- Around line 11-12: Replace the raw <form> and manual CSRF hidden_field_tag
with Rails' form_with helper: call form_with url: link_accounts_brex_items_path,
method: :post and pass the html options (class: "space-y-4", data: {
turbo_frame: "_top" }) so the form uses the same action and attributes; remove
the hidden_field_tag :authenticity_token, form_authenticity_token because
form_with will automatically include the CSRF token. Ensure you update the
closing tag/ERB block that currently wraps the form contents to use form_with's
block form.
In `@db/migrate/20260505010000_create_brex_items_and_accounts.rb`:
- Around line 32-57: The brex_accounts table currently allows NULL for the
currency column but the BrexAccount model requires presence; update the
migration to enforce this at DB level by changing the currency column to null:
false and add a sensible default (e.g. "USD") to make the migration safe for
existing rows (use t.string :currency, null: false, default: "USD"); ensure this
aligns with BrexAccount.currency_code_from_money and any importer logic so
defaults are consistent.
In `@test/models/brex_entry/processor_test.rb`:
- Around line 25-34: The test "imports card purchase with Brex signed amount
preserved" doesn’t assert that merchant data in the saved payload is sanitized;
update this test (involving BrexEntry::Processor and the resulting entry
variable) to explicitly check entry.transaction.extra.dig("brex","merchant")
does not include card_metadata.pan (or any PAN field) and that expected merchant
fields remain present, ensuring the saved extra["brex"]["merchant"] has
sensitive PAN removed while other merchant attributes are preserved.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: a60152d1-280d-4065-86e2-b8805996cecd
📒 Files selected for processing (55)
.gitignoreapp/controllers/accounts_controller.rbapp/controllers/brex_items/account_flows_controller.rbapp/controllers/brex_items/account_setups_controller.rbapp/controllers/brex_items_controller.rbapp/controllers/settings/providers_controller.rbapp/helpers/brex_items_helper.rbapp/models/brex_account.rbapp/models/brex_account/processor.rbapp/models/brex_account/transactions/processor.rbapp/models/brex_entry/processor.rbapp/models/brex_item.rbapp/models/brex_item/account_flow.rbapp/models/brex_item/importer.rbapp/models/brex_item/provided.rbapp/models/brex_item/syncer.rbapp/models/brex_item/unlinking.rbapp/models/concerns/encryptable.rbapp/models/data_enrichment.rbapp/models/family.rbapp/models/family/brex_connectable.rbapp/models/family/syncer.rbapp/models/provider/brex.rbapp/models/provider/brex_adapter.rbapp/models/provider_merchant.rbapp/views/accounts/index.html.erbapp/views/brex_items/_api_error.html.erbapp/views/brex_items/_brex_item.html.erbapp/views/brex_items/_setup_required.html.erbapp/views/brex_items/_subtype_select.html.erbapp/views/brex_items/select_accounts.html.erbapp/views/brex_items/select_existing_account.html.erbapp/views/brex_items/setup_accounts.html.erbapp/views/settings/providers/_brex_panel.html.erbapp/views/settings/providers/show.html.erbconfig/initializers/active_record_encryption.rbconfig/locales/defaults/en.ymlconfig/locales/models/brex_item/en.ymlconfig/locales/views/brex_items/en.ymlconfig/routes.rbdb/migrate/20260505010000_create_brex_items_and_accounts.rbdb/schema.rblib/active_record_encryption_config.rbtest/controllers/brex_items_controller_test.rbtest/fixtures/brex_accounts.ymltest/fixtures/brex_items.ymltest/lib/active_record_encryption_config_test.rbtest/models/brex_account_test.rbtest/models/brex_entry/processor_test.rbtest/models/brex_item/account_flow_test.rbtest/models/brex_item/importer_test.rbtest/models/brex_item/syncer_test.rbtest/models/brex_item_test.rbtest/models/provider/brex_adapter_test.rbtest/models/provider/brex_test.rb
💤 Files with no reviewable changes (1)
- .gitignore
There was a problem hiding this comment.
Actionable comments posted: 7
🧹 Nitpick comments (17)
app/models/brex_item/account_flow.rb (2)
188-202: 💤 Low valueConsider wrapping
link_existing_account!in a transaction for symmetry withlink_new_accounts!.
link_new_accounts!(line 142) wraps upsert + AccountProvider creation inActiveRecord::Base.transaction, butlink_existing_account!does not. IfAccountProvider.create!(line 198) fails, the just-upsertedbrex_account(line 195) is committed without a link. Retries are idempotent so functional impact is low, but a transaction would keep the two paths symmetric and avoid orphaned snapshots when a partial failure occurs.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/models/brex_item/account_flow.rb` around lines 188 - 202, Wrap the upsert + provider creation in link_existing_account! in an ActiveRecord::Base.transaction to match link_new_accounts!: call upsert_brex_account! and AccountProvider.create! inside a transaction so a failed create! rolls back the upsert, then call brex_item.sync_later after the transaction and return brex_account; reference the existing methods link_existing_account!, upsert_brex_account!, AccountProvider.create!, and link_new_accounts! to mirror its behavior.
344-354: 💤 Low valueMethod name suggests a query but performs the import.
import_accounts_error_messagecallsimport_accounts_from_api_if_needed(a side-effecting operation that hits the Brex API and writes to the DB) and only returns an error message on failure. Callers reading this name on a controller would reasonably assume it's a pure error-formatting method. Consider a name likeimport_accounts_with_user_facing_erroror splitting the import call from the rescue/i18n translation.♻️ Possible rename
- def import_accounts_error_message + def import_accounts_or_error_message import_accounts_from_api_if_needed rescue NoApiTokenError I18n.t("brex_items.setup_accounts.no_api_token") ... endAdjust callers accordingly.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/models/brex_item/account_flow.rb` around lines 344 - 354, The method import_accounts_error_message performs a side-effecting import by calling import_accounts_from_api_if_needed but its name implies a pure query; rename it to import_accounts_with_user_facing_error (or split into two: keep import_accounts_from_api_if_needed for the side-effect and create a pure import_accounts_error_message that only formats/errors) and update all callers to either call the new importer method then call the pure error-formatting helper, or call the new combined import_accounts_with_user_facing_error; ensure you update references to import_accounts_error_message and import_accounts_from_api_if_needed accordingly and preserve the same rescue/I18n behavior during the rename/split.app/views/accounts/index.html.erb (1)
52-62: 💤 Low valueInconsistent rendering pattern — Brex uses explicit locals while every other provider uses implicit collection rendering.
Every other provider in this file uses
<%= render@x_items.sort_by(&:created_at) %>and lets the partial pick up shared instance variables itself. The Brex block instead computes locals in the view viabrex_item_render_locals(...). Consider moving the helper call insideapp/views/brex_items/_brex_item.html.erb(or the helper invocation into the partial's first line) so the index can use the same one-liner pattern as Plaid/SimpleFIN/Mercury.♻️ Suggested change
- <% if `@brex_items.any`? %> - <% `@brex_items.sort_by`(&:created_at).each do |brex_item| %> - <%= render partial: "brex_items/brex_item", - locals: brex_item_render_locals( - brex_item, - sync_stats_map: `@brex_sync_stats_map`, - account_counts_map: `@brex_account_counts_map`, - institutions_count_map: `@brex_institutions_count_map` - ) %> - <% end %> - <% end %> + <% if `@brex_items.any`? %> + <%= render `@brex_items.sort_by`(&:created_at) %> + <% end %>Then have
_brex_item.html.erbopen with<% locals = brex_item_render_locals(brex_item, sync_stats_map:@brex_sync_stats_map, account_counts_map:@brex_account_counts_map, institutions_count_map:@brex_institutions_count_map) %>(or merge directly into the existing locals).🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/views/accounts/index.html.erb` around lines 52 - 62, Replace the explicit per-item locals in the index view by rendering the Brex collection like the others (use render `@brex_items.sort_by`(&:created_at)), and move the brex_item_render_locals(...) call into the Brex partial so it computes needed locals for each brex_item; specifically, update app/views/brex_items/_brex_item.html.erb to begin by calling brex_item_render_locals(brex_item, sync_stats_map: `@brex_sync_stats_map`, account_counts_map: `@brex_account_counts_map`, institutions_count_map: `@brex_institutions_count_map`) (or merge its values into the partial’s existing locals) and adjust the partial to use those locals so the index block can be a one-liner like the other providers.test/models/brex_account/transactions/processor_test.rb (1)
24-34: 💤 Low valueCoverage is narrow — consider asserting the success/imported path too.
This file only validates the "no linked account" skip branch. The processor surface (
total/imported/skipped/failed/errors/skipped_transactions) is broad enough that a happy-path test (linked account + imported entry) and at least one failure-path test (e.g., processor raising for one entry while others succeed) would lock in the aggregation contract. Not a blocker; flagging as a coverage gap rather than a defect.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@test/models/brex_account/transactions/processor_test.rb` around lines 24 - 34, Add two more tests for BrexAccount::Transactions::Processor#process: one "happy-path" that sets up a linked account and verifies result[:success] true, result[:total] increments, result[:imported] increments, result[:skipped] and result[:failed] are zero, and skipped_transactions/errors are empty; and one "partial-failure" test that rigs one entry to raise (or simulate a failed import) while others succeed and then asserts aggregated counts (total, imported, skipped, failed), presence of an error in result[:errors], and that the failed entry is represented appropriately in result[:failed] or result[:errors]; use the same result keys (total/imported/skipped/failed/errors/skipped_transactions) and BrexAccount::Transactions::Processor.new(`@brex_account`).process to exercise the aggregation contract.app/views/brex_items/_brex_item.html.erb (1)
22-24: 💤 Low valueUse semantic markup consistent with surrounding
tag.p.Line 21 uses
tag.pfor the item name and line 23 uses a raw<p>for the deletion-in-progress label inside the same flex row. This works but is inconsistent with the helper-based tag usage elsewhere in the partial. Usingtag.pkeeps the file consistent and benefits from automatic escaping/CSP-friendliness.♻️ Suggested tweak
- <% if brex_item.scheduled_for_deletion? %> - <p class="text-destructive text-sm animate-pulse"><%= t(".deletion_in_progress") %></p> - <% end %> + <% if brex_item.scheduled_for_deletion? %> + <%= tag.p t(".deletion_in_progress"), class: "text-destructive text-sm animate-pulse" %> + <% end %>🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/views/brex_items/_brex_item.html.erb` around lines 22 - 24, Replace the raw HTML paragraph used for the deletion label with the Rails tag helper to match the surrounding markup: in the conditional that checks brex_item.scheduled_for_deletion? swap the literal <p class="..."> containing t(".deletion_in_progress") for tag.p with the same class list (e.g., "text-destructive text-sm animate-pulse") so it uses the helper-based rendering and automatic escaping consistent with the other tag.p usage in this partial.app/controllers/brex_items/account_flows_controller.rb (2)
30-55: ⚡ Quick winHandle missing account ids without surfacing a 404 to the user.
Current.family.accounts.find(params[:account_id])on lines 33 and 48 will raiseActiveRecord::RecordNotFoundif the id is for an account from another family or simply doesn't exist. UnlessApplicationControlleralready rescues this for HTML/Turbo flows (and renders a friendly redirect), a malformed/expired link will produce a 404 inside a Turbo modal flow rather than aredirect_to accounts_pathwith a flash. Recommend either usingfind_by(id: …)with an explicit redirect (mirroring theparams[:account_id].blank?early return) or adding arescue_from ActiveRecord::RecordNotFoundfor this controller.♻️ Suggested change
def select_existing_account return redirect_to accounts_path, alert: t("brex_items.select_existing_account.no_account_specified") if params[:account_id].blank? - `@account` = Current.family.accounts.find(params[:account_id]) + `@account` = Current.family.accounts.find_by(id: params[:account_id]) + return redirect_to accounts_path, alert: t("brex_items.select_existing_account.no_account_specified") if `@account.nil`? result = brex_account_flow.select_existing_account_result(account: `@account`)🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/controllers/brex_items/account_flows_controller.rb` around lines 30 - 55, The controller currently calls Current.family.accounts.find(params[:account_id]) in select_existing_account and link_existing_account which will raise ActiveRecord::RecordNotFound for invalid/other-family IDs; change both to use Current.family.accounts.find_by(id: params[:account_id]) and if that returns nil perform the same redirect used for blank params (redirect_to accounts_path, alert: t("brex_items.select_existing_account.no_account_specified") for select_existing_account and the analogous t("brex_items.link_existing_account.no_account_specified") or reuse the same message for link_existing_account), so that brex_account_flow.select_existing_account_result and brex_account_flow.link_existing_account_result are only called with a valid account and no 404 is surfaced in Turbo/modal flows.
98-116: 💤 Low valueConsider rejecting percent-encoded slash/backslash sequences in
safe_return_to_pathfor defense in depth.The current implementation correctly blocks
//evil,/\evil, control chars, whitespace, and absolute URLs. However, percent-encoded variants such as/%2fevil.example/pathand/%5cevil.example/pathwill pass:URI.parsekeeps them as a path with no scheme/host, and the second character check sees%, not/or\. While most browsers don't decode these inLocationheaders, intermediaries (proxies, link rewriters) sometimes do, and adding the check is essentially free.🛡️ Suggested hardening
second_character = return_to[1] return nil if second_character.blank? return nil if second_character == "/" || second_character == "\\" return nil if second_character.match?(/[[:space:][:cntrl:]]/) + return nil if return_to.match?(/\A\/(?:%2f|%5c)/i) uri = URI.parse(return_to)🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/controllers/brex_items/account_flows_controller.rb` around lines 98 - 116, The safe_return_to_path method should reject percent-encoded slash/backslash sequences to harden against proxies that decode them; after computing return_to (from params[:return_to].to_s.strip) and before URI.parse, check the bytes at positions 1..3 (the percent-encoded sequence immediately after the leading "/") and return nil if they match %2f or %5c (case-insensitive) or otherwise decode to "/" or "\"; keep the existing second_character checks and the URI.parse host/scheme checks but add this explicit percent-encoding rejection so strings like "/%2fevil" or "/%5cevil" are rejected by safe_return_to_path.test/controllers/brex_items_controller_test.rb (1)
313-347: 💤 Low valueVerify the unsupported-type branch covers the actual production filter.
The test asserts that passing
account_types[unsupported_brex_account.id] = "Investment"results inunsupported_brex_account.account_providerbeingnilaftercomplete_account_setup. That's a meaningful assertion, but it only confirms a side-effect, not which guard rejected it (unsupportedaccountable_type, unsupportedaccount_kind, missingbrex_accountlookup, etc.). Consider also asserting thatflash[:notice](or an analogous message) reports a partial setup, or that the controller exposes a count of skipped/unsupported entries — otherwise a future refactor that silently swallows valid Investment requests would still pass this test.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@test/controllers/brex_items_controller_test.rb` around lines 313 - 347, The test currently only checks the side-effect that unsupported_brex_account.account_provider is nil after calling complete_account_setup_brex_item_url; update the test to also assert the controller reported the skipped/unsupported entry (e.g. assert_match /skipp|unsupported|partial/i, flash[:notice] || flash[:alert]) so you verify the actual guard that rejected the "Investment" account type is surfaced to the user; if the controller doesn't set such a flash, modify the complete_account_setup action to set flash[:notice] with a message or expose a skipped_count (e.g. `@skipped_count`) and assert that value in the test instead (referencing complete_account_setup_brex_item_url, unsupported_brex_account, and AccountProvider.count).app/views/brex_items/select_accounts.html.erb (1)
19-43: 💤 Low valueProvide an explicit submit-disabled state when no selectable accounts exist.
If every entry in
@available_accountshas a blank name, every checkbox isdisabled, but the "Link Accounts" submit button stays enabled. Submitting then postsaccount_ids=[]tolink_accounts_brex_items_path, which the controller has to special-case. Consider disabling the submit button (and/or rendering an empty-state message) when@available_accounts.none? { |a| !brex_account_display(a).blank_name? }so the UI doesn't invite a no-op submission.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/views/brex_items/select_accounts.html.erb` around lines 19 - 43, The form should disable the submit action when there are no selectable accounts: compute a boolean like has_selectable = `@available_accounts.any`? { |a| !brex_account_display(a).blank_name? } and use it to disable the "Link Accounts" submit button (e.g. add disabled: true and a disabled visual style/class) and/or render an explicit empty-state message; update any view helper usage around brex_account_display and check_box_tag to rely on has_selectable so the submit cannot be clicked when all checkboxes are disabled.app/controllers/brex_items/account_setups_controller.rb (1)
1-3: 💤 Low valueRun
require_admin!beforeset_brex_item.
set_brex_itemtriggers a database lookup using the request param before the admin check has run. Reordering the callbacks ensures non-admins are rejected without any DB access and avoids leaking error states (e.g.,RecordNotFound) that could differentiate between "missing item" and "not allowed" for non-admin callers.♻️ Proposed reorder
- before_action :set_brex_item - before_action :require_admin! + before_action :require_admin! + before_action :set_brex_item🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/controllers/brex_items/account_setups_controller.rb` around lines 1 - 3, The before_action ordering in BrexItems::AccountSetupsController calls set_brex_item before require_admin!, causing a DB lookup (and potential RecordNotFound) before authorization; swap the callbacks so require_admin! runs first (ensure the controller uses before_action :require_admin! followed by before_action :set_brex_item) to reject non-admins without accessing the database or leaking error details.app/models/brex_account.rb (3)
161-167: 💤 Low valueReplace
assign_attributes+save!withupdate!.There's no intermediate logic between assignment and save, so
update!is more idiomatic and equivalent.♻️ Proposed simplification
def upsert_brex_transactions_snapshot!(transactions_snapshot) - assign_attributes( - raw_transactions_payload: self.class.sanitize_payload(transactions_snapshot) - ) - - save! + update!(raw_transactions_payload: self.class.sanitize_payload(transactions_snapshot)) end🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/models/brex_account.rb` around lines 161 - 167, The method upsert_brex_transactions_snapshot!(transactions_snapshot) currently calls assign_attributes(...) followed by save!; replace that pair with a single call to update! to be idiomatic and atomic. Specifically, update the method to call update!(raw_transactions_payload: self.class.sanitize_payload(transactions_snapshot)) instead of assign_attributes(...) and save! so the sanitized payload is persisted in one step.
64-77: ⚡ Quick win
money_to_decimalrescue path can re-raiseArgumentError.If
BigDecimal(amount.to_s)raisesArgumentError(e.g., the API returns a non-numericamount), the rescue branch executesBigDecimal(payload[:amount].to_s)again on the same value, which will raise the same exception and propagate out of the method. Either (a) hard-default toBigDecimal("0")in the rescue or (b) catch the recurrence inside the rescue.🛡️ Suggested fix
rescue Money::Currency::UnknownCurrencyError, ArgumentError Rails.logger.warn("Invalid Brex money payload #{money_payload.inspect}, defaulting conversion to USD") - BigDecimal(payload[:amount].to_s) / BigDecimal(Money::Currency.new("USD").minor_unit_conversion.to_s) + begin + BigDecimal(payload[:amount].to_s) / BigDecimal(Money::Currency.new("USD").minor_unit_conversion.to_s) + rescue ArgumentError + nil + end end🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/models/brex_account.rb` around lines 64 - 77, In money_to_decimal, the rescue currently retries BigDecimal(payload[:amount].to_s) which can re-raise the same ArgumentError; update the rescue block in the money_to_decimal method to avoid propagating ArgumentError by wrapping the fallback BigDecimal(...) in its own safe handler (or simply return BigDecimal("0") on failure), ensure you still log the original payload (Rails.logger.warn(...)) and avoid calling Money::Currency.new("USD").minor_unit_conversion on invalid amounts unless guarded; reference the money_to_decimal method, the rescue clause, BigDecimal(...) and Money::Currency usage when making the change.
13-15: 💤 Low value
linked_accountduplicatesaccount.Both
accountandlinked_accountare declaredhas_one :through => :account_provider, source: :account, so they resolve to the same record. Pick one canonical name — using both invites confusion (e.g., callinglinked_accountandaccounttriggers two distinct queries because each association caches independently).♻️ Proposed simplification
has_one :account_provider, as: :provider, dependent: :destroy has_one :account, through: :account_provider, source: :account - has_one :linked_account, through: :account_provider, source: :accountIf
linked_accountis required for compatibility with existing call sites, alias it instead:alias_method :linked_account, :account🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/models/brex_account.rb` around lines 13 - 15, The BrexAccount model defines both has_one :account and has_one :linked_account through :account_provider with the same source (:account), causing duplicate, uncached queries and confusion; fix by removing the redundant has_one :linked_account and update callers to use :account, or if :linked_account must remain for compatibility, replace the association with an alias (alias_method :linked_account, :account) so both names refer to the same cached association; locate the duplicate declarations in the BrexAccount class (has_one :account, has_one :linked_account) and either delete the linked_account association or convert it to an alias.test/models/brex_item/importer_test.rb (1)
25-47: 💤 Low valueTighten the
start_datematcher to assert the exact value passed.This test passes
Date.new(2026, 1, 1)to the importer, so the matcher could assertstart_date: Date.new(2026, 1, 1)instead ofanythingand still verify the same boundary contract that "uses explicit sync start date..." (line 132) covers. Usinganythinghere weakens the assertion that the importer threadssync_start_dateintoget_cash_transactionsfor the discovery flow.♻️ Suggested tightening
- provider.expects(:get_cash_transactions).with("cash_1", start_date: anything).returns( + provider.expects(:get_cash_transactions).with("cash_1", start_date: Date.new(2026, 1, 1)).returns(🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@test/models/brex_item/importer_test.rb` around lines 25 - 47, The provider expectation for get_cash_transactions uses a loose matcher (anything) for start_date; tighten it to expect the exact sync start date passed to BrexItem::Importer so the test verifies threading of sync_start_date through discovery. Update the mock expectation on provider.get_cash_transactions to use start_date: Date.new(2026, 1, 1) (matching the sync_start_date argument passed to BrexItem::Importer.new) instead of anything; keep the rest of the expectation and assertions unchanged.app/models/provider/brex_adapter.rb (1)
115-131: 💤 Low valuePrefer
metadata&.dig("name")overmetadata&.[]("name").The
&.[]form works but is unidiomatic and harder to read.digis the conventional Ruby way to safely access a nested hash key.♻️ Proposed simplification
def institution_name metadata = provider_account.institution_metadata - - metadata&.[]("name") || item&.institution_name + metadata&.dig("name") || item&.institution_name end def institution_url metadata = provider_account.institution_metadata - - metadata&.[]("url") || item&.institution_url + metadata&.dig("url") || item&.institution_url end def institution_color metadata = provider_account.institution_metadata - - metadata&.[]("color") || item&.institution_color + metadata&.dig("color") || item&.institution_color end🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/models/provider/brex_adapter.rb` around lines 115 - 131, Replace the unidiomatic safe-access calls using metadata&.[]("key") with metadata&.dig("key") in the three accessors: institution_name, institution_url, and institution_color; update the code that reads provider_account.institution_metadata into metadata and return metadata&.dig("name") || item&.institution_name, metadata&.dig("url") || item&.institution_url, and metadata&.dig("color") || item&.institution_color respectively to make the hash lookups clearer and idiomatic.app/models/brex_item.rb (2)
128-133: 💤 Low value
includes(:account)is unused inconnected_institutions.The block only reads
acc.institution_metadata, which is a column onbrex_accountsitself — there's no traversal of theaccountassociation inside the iteration. The eager-load is dead weight and will issue an extra query per call.♻️ Proposed simplification
def connected_institutions - brex_accounts.includes(:account) - .where.not(institution_metadata: nil) + brex_accounts.where.not(institution_metadata: nil) .map { |acc| acc.institution_metadata } .uniq { |inst| inst["name"] || inst["institution_name"] } end🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/models/brex_item.rb` around lines 128 - 133, The includes(:account) in the connected_institutions method is unnecessary because the code only reads acc.institution_metadata (a column on brex_accounts) and does not traverse the account association; remove the eager-load and instead iterate or pluck directly from brex_accounts (e.g., use brex_accounts.where.not(institution_metadata: nil).pluck(:institution_metadata).uniq { |inst| inst["name"] || inst["institution_name"] }) to avoid the extra query and de-duplicate by the same uniqueness key.
85-91: 💤 Low valueUse
update!instead ofassign_attributes+save!.There's no in-between logic, so
update!is equivalent and more idiomatic. (Same pattern inBrexAccount#upsert_brex_transactions_snapshot!.)♻️ Proposed simplification
def upsert_brex_snapshot!(accounts_snapshot) - assign_attributes( - raw_payload: BrexAccount.sanitize_payload(accounts_snapshot) - ) - - save! + update!(raw_payload: BrexAccount.sanitize_payload(accounts_snapshot)) end🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/models/brex_item.rb` around lines 85 - 91, Replace the assign_attributes + save! pattern in the method upsert_brex_snapshot! with a single update! call: call update!(raw_payload: BrexAccount.sanitize_payload(accounts_snapshot)) inside the upsert_brex_snapshot! method; do the same refactor for the analogous method BrexAccount#upsert_brex_transactions_snapshot! if present to keep the pattern consistent and idiomatic.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@app/controllers/brex_items/account_flows_controller.rb`:
- Around line 4-28: The link_accounts path currently forwards
params[:accountable_type] into brex_account_flow.link_new_accounts_result →
link_new_accounts! without validating the value; add the same validation pattern
used in complete_setup! to link_new_accounts_result (or at the start of
link_new_accounts!) to reject unsupported accountable_type values before calling
Account.create_and_sync or BrexAccount.default_accountable_attributes, e.g.,
ensure accountable_type is one of the allowed types (matching filter_accounts)
and return a failed Result (or raise/handle) when invalid so attacker-supplied
values are not forwarded to Account.create_and_sync or
BrexAccount.default_accountable_attributes.
In `@app/models/brex_entry/processor.rb`:
- Around line 64-69: The fallback literal "Brex transaction" in the name method
should be replaced with a translation call and an i18n key; update the def name
method to use t('brex_items.entries.default_name') as the final fallback instead
of the hardcoded string (method name: name in the Brex entry processor /
Entry#name), and add the corresponding key brex_items.entries.default_name:
"Brex transaction" to config/locales/en.yml so the user-facing default is
localized.
- Around line 30-39: The rescue handlers call external_id during log
interpolation which can re-raise ArgumentError and swallow the intended log; fix
this by reading/caching the external id into a local variable once (e.g.,
cached_external_id = external_id or derive safely from data[:id]) before the
begin/rescue region and then use that cached_external_id in the rescue log lines
inside the rescue blocks (the handlers for ArgumentError,
ActiveRecord::RecordInvalid/RecordNotSaved, and the generic rescue) so
interpolation cannot raise while logging.
In `@app/models/family/brex_connectable.rb`:
- Around line 14-24: Add a before_validation callback on the BrexItem model to
strip and normalize the token the same way normalize_base_url does for base_url:
implement a private method (e.g., normalize_token) that replaces token with
token&.strip (or nil-safe equivalent) and register it via before_validation
:normalize_token; this ensures tokens created via Family#create_brex_item! or
any other non-controller code are normalized before validation/save.
In `@app/views/brex_items/_brex_item.html.erb`:
- Around line 36-40: The stored sync error may contain raw exception text
because Sync#perform currently does update(error: e.message) for any unhandled
exception; modify the error-handling so that BrexItem::Syncer#perform_sync (and
any helper methods it calls) rescue exceptions and raise or return only curated,
user-safe messages (e.g., standardized strings like "Invalid Brex API token or
account permissions" or "Sync failed, contact support") instead of raw
e.message, or alternatively sanitize inside Sync#perform before persisting (map
specific known exception classes to friendly messages and fall back to a generic
message for all others while logging the original exception). Ensure the
original exception is still logged/reported to Sentry/Logs but never written to
brex_item.sync_error that the view renders.
In `@app/views/brex_items/select_accounts.html.erb`:
- Line 22: The label element that conditions on account_display.blank_name? is
using non-existent Tailwind utilities (border-error, bg-error/5, text-error);
update that label's class logic to use design-system tokens: replace
border-error with border-destructive, replace bg-error/5 with a semantic
destructive background (either bg-destructive-soft if you add that token or use
an existing bg-destructive with an opacity wrapper), and swap text-error
occurrences to the appropriate token (text-primary for normal emphasis or
text-destructive if you introduce destructive text styling); keep the existing
conditional on account_display.blank_name? and ensure the disabled-style classes
(cursor-not-allowed opacity-60) remain applied when blank_name? is true.
In `@db/migrate/20260505010000_create_brex_items_and_accounts.rb`:
- Around line 15-17: The migration currently sets defaults but leaves nullable
columns for status, scheduled_for_deletion, and pending_account_setup; update
the migration that defines these columns (the CreateBrexItemsAndAccounts
migration) to add null: false to t.string :status, t.boolean
:scheduled_for_deletion, and t.boolean :pending_account_setup so the DB enforces
non-null values alongside their defaults.
---
Nitpick comments:
In `@app/controllers/brex_items/account_flows_controller.rb`:
- Around line 30-55: The controller currently calls
Current.family.accounts.find(params[:account_id]) in select_existing_account and
link_existing_account which will raise ActiveRecord::RecordNotFound for
invalid/other-family IDs; change both to use Current.family.accounts.find_by(id:
params[:account_id]) and if that returns nil perform the same redirect used for
blank params (redirect_to accounts_path, alert:
t("brex_items.select_existing_account.no_account_specified") for
select_existing_account and the analogous
t("brex_items.link_existing_account.no_account_specified") or reuse the same
message for link_existing_account), so that
brex_account_flow.select_existing_account_result and
brex_account_flow.link_existing_account_result are only called with a valid
account and no 404 is surfaced in Turbo/modal flows.
- Around line 98-116: The safe_return_to_path method should reject
percent-encoded slash/backslash sequences to harden against proxies that decode
them; after computing return_to (from params[:return_to].to_s.strip) and before
URI.parse, check the bytes at positions 1..3 (the percent-encoded sequence
immediately after the leading "/") and return nil if they match %2f or %5c
(case-insensitive) or otherwise decode to "/" or "\"; keep the existing
second_character checks and the URI.parse host/scheme checks but add this
explicit percent-encoding rejection so strings like "/%2fevil" or "/%5cevil" are
rejected by safe_return_to_path.
In `@app/controllers/brex_items/account_setups_controller.rb`:
- Around line 1-3: The before_action ordering in
BrexItems::AccountSetupsController calls set_brex_item before require_admin!,
causing a DB lookup (and potential RecordNotFound) before authorization; swap
the callbacks so require_admin! runs first (ensure the controller uses
before_action :require_admin! followed by before_action :set_brex_item) to
reject non-admins without accessing the database or leaking error details.
In `@app/models/brex_account.rb`:
- Around line 161-167: The method
upsert_brex_transactions_snapshot!(transactions_snapshot) currently calls
assign_attributes(...) followed by save!; replace that pair with a single call
to update! to be idiomatic and atomic. Specifically, update the method to call
update!(raw_transactions_payload:
self.class.sanitize_payload(transactions_snapshot)) instead of
assign_attributes(...) and save! so the sanitized payload is persisted in one
step.
- Around line 64-77: In money_to_decimal, the rescue currently retries
BigDecimal(payload[:amount].to_s) which can re-raise the same ArgumentError;
update the rescue block in the money_to_decimal method to avoid propagating
ArgumentError by wrapping the fallback BigDecimal(...) in its own safe handler
(or simply return BigDecimal("0") on failure), ensure you still log the original
payload (Rails.logger.warn(...)) and avoid calling
Money::Currency.new("USD").minor_unit_conversion on invalid amounts unless
guarded; reference the money_to_decimal method, the rescue clause,
BigDecimal(...) and Money::Currency usage when making the change.
- Around line 13-15: The BrexAccount model defines both has_one :account and
has_one :linked_account through :account_provider with the same source
(:account), causing duplicate, uncached queries and confusion; fix by removing
the redundant has_one :linked_account and update callers to use :account, or if
:linked_account must remain for compatibility, replace the association with an
alias (alias_method :linked_account, :account) so both names refer to the same
cached association; locate the duplicate declarations in the BrexAccount class
(has_one :account, has_one :linked_account) and either delete the linked_account
association or convert it to an alias.
In `@app/models/brex_item.rb`:
- Around line 128-133: The includes(:account) in the connected_institutions
method is unnecessary because the code only reads acc.institution_metadata (a
column on brex_accounts) and does not traverse the account association; remove
the eager-load and instead iterate or pluck directly from brex_accounts (e.g.,
use brex_accounts.where.not(institution_metadata:
nil).pluck(:institution_metadata).uniq { |inst| inst["name"] ||
inst["institution_name"] }) to avoid the extra query and de-duplicate by the
same uniqueness key.
- Around line 85-91: Replace the assign_attributes + save! pattern in the method
upsert_brex_snapshot! with a single update! call: call update!(raw_payload:
BrexAccount.sanitize_payload(accounts_snapshot)) inside the
upsert_brex_snapshot! method; do the same refactor for the analogous method
BrexAccount#upsert_brex_transactions_snapshot! if present to keep the pattern
consistent and idiomatic.
In `@app/models/brex_item/account_flow.rb`:
- Around line 188-202: Wrap the upsert + provider creation in
link_existing_account! in an ActiveRecord::Base.transaction to match
link_new_accounts!: call upsert_brex_account! and AccountProvider.create! inside
a transaction so a failed create! rolls back the upsert, then call
brex_item.sync_later after the transaction and return brex_account; reference
the existing methods link_existing_account!, upsert_brex_account!,
AccountProvider.create!, and link_new_accounts! to mirror its behavior.
- Around line 344-354: The method import_accounts_error_message performs a
side-effecting import by calling import_accounts_from_api_if_needed but its name
implies a pure query; rename it to import_accounts_with_user_facing_error (or
split into two: keep import_accounts_from_api_if_needed for the side-effect and
create a pure import_accounts_error_message that only formats/errors) and update
all callers to either call the new importer method then call the pure
error-formatting helper, or call the new combined
import_accounts_with_user_facing_error; ensure you update references to
import_accounts_error_message and import_accounts_from_api_if_needed accordingly
and preserve the same rescue/I18n behavior during the rename/split.
In `@app/models/provider/brex_adapter.rb`:
- Around line 115-131: Replace the unidiomatic safe-access calls using
metadata&.[]("key") with metadata&.dig("key") in the three accessors:
institution_name, institution_url, and institution_color; update the code that
reads provider_account.institution_metadata into metadata and return
metadata&.dig("name") || item&.institution_name, metadata&.dig("url") ||
item&.institution_url, and metadata&.dig("color") || item&.institution_color
respectively to make the hash lookups clearer and idiomatic.
In `@app/views/accounts/index.html.erb`:
- Around line 52-62: Replace the explicit per-item locals in the index view by
rendering the Brex collection like the others (use render
`@brex_items.sort_by`(&:created_at)), and move the brex_item_render_locals(...)
call into the Brex partial so it computes needed locals for each brex_item;
specifically, update app/views/brex_items/_brex_item.html.erb to begin by
calling brex_item_render_locals(brex_item, sync_stats_map: `@brex_sync_stats_map`,
account_counts_map: `@brex_account_counts_map`, institutions_count_map:
`@brex_institutions_count_map`) (or merge its values into the partial’s existing
locals) and adjust the partial to use those locals so the index block can be a
one-liner like the other providers.
In `@app/views/brex_items/_brex_item.html.erb`:
- Around line 22-24: Replace the raw HTML paragraph used for the deletion label
with the Rails tag helper to match the surrounding markup: in the conditional
that checks brex_item.scheduled_for_deletion? swap the literal <p class="...">
containing t(".deletion_in_progress") for tag.p with the same class list (e.g.,
"text-destructive text-sm animate-pulse") so it uses the helper-based rendering
and automatic escaping consistent with the other tag.p usage in this partial.
In `@app/views/brex_items/select_accounts.html.erb`:
- Around line 19-43: The form should disable the submit action when there are no
selectable accounts: compute a boolean like has_selectable =
`@available_accounts.any`? { |a| !brex_account_display(a).blank_name? } and use it
to disable the "Link Accounts" submit button (e.g. add disabled: true and a
disabled visual style/class) and/or render an explicit empty-state message;
update any view helper usage around brex_account_display and check_box_tag to
rely on has_selectable so the submit cannot be clicked when all checkboxes are
disabled.
In `@test/controllers/brex_items_controller_test.rb`:
- Around line 313-347: The test currently only checks the side-effect that
unsupported_brex_account.account_provider is nil after calling
complete_account_setup_brex_item_url; update the test to also assert the
controller reported the skipped/unsupported entry (e.g. assert_match
/skipp|unsupported|partial/i, flash[:notice] || flash[:alert]) so you verify the
actual guard that rejected the "Investment" account type is surfaced to the
user; if the controller doesn't set such a flash, modify the
complete_account_setup action to set flash[:notice] with a message or expose a
skipped_count (e.g. `@skipped_count`) and assert that value in the test instead
(referencing complete_account_setup_brex_item_url, unsupported_brex_account, and
AccountProvider.count).
In `@test/models/brex_account/transactions/processor_test.rb`:
- Around line 24-34: Add two more tests for
BrexAccount::Transactions::Processor#process: one "happy-path" that sets up a
linked account and verifies result[:success] true, result[:total] increments,
result[:imported] increments, result[:skipped] and result[:failed] are zero, and
skipped_transactions/errors are empty; and one "partial-failure" test that rigs
one entry to raise (or simulate a failed import) while others succeed and then
asserts aggregated counts (total, imported, skipped, failed), presence of an
error in result[:errors], and that the failed entry is represented appropriately
in result[:failed] or result[:errors]; use the same result keys
(total/imported/skipped/failed/errors/skipped_transactions) and
BrexAccount::Transactions::Processor.new(`@brex_account`).process to exercise the
aggregation contract.
In `@test/models/brex_item/importer_test.rb`:
- Around line 25-47: The provider expectation for get_cash_transactions uses a
loose matcher (anything) for start_date; tighten it to expect the exact sync
start date passed to BrexItem::Importer so the test verifies threading of
sync_start_date through discovery. Update the mock expectation on
provider.get_cash_transactions to use start_date: Date.new(2026, 1, 1) (matching
the sync_start_date argument passed to BrexItem::Importer.new) instead of
anything; keep the rest of the expectation and assertions unchanged.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 5056605e-3e60-4421-9eee-a28f3546a57f
📒 Files selected for processing (34)
app/components/DS/dialog.rbapp/controllers/accounts_controller.rbapp/controllers/brex_items/account_flows_controller.rbapp/controllers/brex_items/account_setups_controller.rbapp/helpers/brex_items_helper.rbapp/models/brex_account.rbapp/models/brex_account/processor.rbapp/models/brex_account/transactions/processor.rbapp/models/brex_entry/processor.rbapp/models/brex_item.rbapp/models/brex_item/account_flow.rbapp/models/brex_item/importer.rbapp/models/credit_card.rbapp/models/depository.rbapp/models/family/brex_connectable.rbapp/models/provider/brex.rbapp/models/provider/brex_adapter.rbapp/views/accounts/index.html.erbapp/views/brex_items/_brex_item.html.erbapp/views/brex_items/select_accounts.html.erbapp/views/settings/providers/_brex_panel.html.erbapp/views/settings/providers/show.html.erbconfig/locales/defaults/en.ymlconfig/locales/views/brex_items/en.ymlconfig/locales/views/settings/en.ymldb/migrate/20260505010000_create_brex_items_and_accounts.rbdb/schema.rbtest/controllers/brex_items_controller_test.rbtest/models/brex_account/transactions/processor_test.rbtest/models/brex_account_test.rbtest/models/brex_entry/processor_test.rbtest/models/brex_item/account_flow_test.rbtest/models/brex_item/importer_test.rbtest/models/provider/brex_test.rb
🚧 Files skipped from review as they are similar to previous changes (4)
- app/views/settings/providers/_brex_panel.html.erb
- app/models/brex_item/importer.rb
- test/models/provider/brex_test.rb
- app/models/provider/brex.rb
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (6)
app/models/brex_item/account_flow.rb (1)
67-83: ⚡ Quick winReplace the
exist?+readpair with a single cache read.
Rails.cache.exist?(cache_key)followed byaccounts(which callsRails.cache.readinternally on line 558) issues two cache lookups perpreload_payload. Beyond the extra round-trip, there's a small TOCTOU window where the entry can expire betweenexist?andread: in that caseaccountswill re-fetch from the Brex API and rewrite the cache, but the payload still reportscached: true. Reading once and derivingcachedfrom the result fixes both the inefficiency and the reporting drift.♻️ Suggested refactor
def preload_payload return selection_error_payload if !selected? return { success: false, error: "no_credentials", has_accounts: false } unless brex_item&.credentials_configured? - cached = Rails.cache.exist?(cache_key) - available_accounts = accounts + available_accounts = Rails.cache.read(cache_key) + cached = !available_accounts.nil? + unless cached + available_accounts = fetch_accounts + Rails.cache.write(cache_key, available_accounts, expires_in: CACHE_TTL) + end { success: true, has_accounts: available_accounts.any?, cached: cached }Note: the existing
accountshelper can stay for other call sites (unlinked_available_accounts,indexed_accounts) that don't care about cache-hit reporting.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/models/brex_item/account_flow.rb` around lines 67 - 83, Replace the two cache lookups by doing a single Rails.cache.read(cache_key) inside preload_payload: call cached_value = Rails.cache.read(cache_key), set cached = !cached_value.nil? (or cached_value.present? if you want to treat empty arrays as not-cached), then set available_accounts = cached_value || accounts (so accounts is only called when the cache miss happens). Keep using the existing accounts helper for the fetch/path that writes the cache; update the payload to return cached based on the single read result.app/views/brex_items/select_existing_account.html.erb (1)
24-24: 💤 Low valueUse a functional surface token instead of the raw
bg-red-tint-5color tint.The code pairs functional tokens (
border-destructive,text-destructive) with a raw color tint token (bg-red-tint-5), which violates the design system guideline: "Always prefer using functional tokens defined in sure-design-system.css rather than raw Tailwind utilities or arbitrary colors."The design system currently lacks a destructive surface token. Replace
bg-red-tint-5with an existing functional surface token (e.g.,bg-surface,bg-container) or add a new destructive surface utility (e.g.,bg-destructive-subtle) to the design system for consistency.This pattern appears in both
select_existing_account.html.erbandselect_accounts.html.erbat line 24, so a single fix covers both.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/views/brex_items/select_existing_account.html.erb` at line 24, Replace the raw color token bg-red-tint-5 used in the label class conditional with a functional surface token (e.g., bg-destructive-subtle or bg-surface/bg-container) so the destructive state uses design-system semantics; update the conditional in the label for account_display.blank_name? inside select_existing_account.html.erb (and the identical spot in select_accounts.html.erb) to use the chosen functional utility (e.g., change "bg-red-tint-5" to "bg-destructive-subtle" or "bg-surface") and if your design system lacks a destructive surface token, add a new utility (bg-destructive-subtle) to sure-design-system.css and use that class in the label conditional.app/models/brex_item.rb (1)
86-88: 💤 Low valueEncryption inconsistency:
BrexItem.raw_payloadvsBrexAccount.raw_payload.
BrexAccountdeclaresencrypts :raw_payload, butBrexItem.raw_payloadis stored in plaintext. Both callBrexAccount.sanitize_payloadto redact PII/secrets, so the immediate exposure is limited—but the asymmetry is surprising and a future change tosanitize_payload(or a missed sensitive key) loses the encryption-at-rest defense in depth on the parent record. Either encrypt both or document why the parent snapshot is intentionally plaintext.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/models/brex_item.rb` around lines 86 - 88, BrexItem currently stores raw_payload plaintext in upsert_brex_snapshot! while BrexAccount declares encrypts :raw_payload; to fix, add symmetric encryption on the parent model by declaring encrypts :raw_payload in the BrexItem model (so BrexItem.upsert_brex_snapshot! continues to call BrexAccount.sanitize_payload but the stored snapshot is encrypted at rest), or if intentional, add an explicit comment in BrexItem documenting why raw_payload is stored unencrypted and reference BrexAccount.sanitize_payload and the threat model.app/models/brex_item/syncer.rb (3)
50-50: ⚡ Quick winN+1 query:
current_accountnot preloaded.
current_accountdelegates to theaccountassociation, which ishas_one :account, through: :account_provider, source: :account.includes(:account_provider)only preloadsaccount_provider, not theaccountreachable through it. Eachma.current_account&.idcall triggers an additional query per linked account.Proposed fix to preload through the association chain
- account_ids = linked_accounts.includes(:account_provider).filter_map { |ma| ma.current_account&.id } + account_ids = linked_accounts.includes(account_provider: :account).filter_map { |ma| ma.current_account&.id }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/models/brex_item/syncer.rb` at line 50, The N+1 stems from calling ma.current_account on each linked_account while only preloading :account_provider; update the preload to include the through association so current_account is eager loaded (e.g. change the includes call on linked_accounts from includes(:account_provider) to include the account via the provider such as includes(account_provider: :account) or the direct association name if defined), then keep the filter_map { |ma| ma.current_account&.id } unchanged so no extra queries are issued.
22-31: 💤 Low valueMinor: redundant queries on
unlinked_accounts.
unlinked_accounts.any?(line 26) andunlinked_accounts.count(line 28) each fire their own LEFT JOIN query. Since the count is used both for branching and the status text, materializing once would halve the round-trips. Same pattern repeats forlinked_accounts.any?/linked_accounts.counton lines 34/37.Proposed micro-optimization
- if unlinked_accounts.any? + unlinked_count = unlinked_accounts.count + if unlinked_count.positive? brex_item.update!(pending_account_setup: true) - sync.update!(status_text: "#{unlinked_accounts.count} accounts need setup...") if sync.respond_to?(:status_text) + sync.update!(status_text: "#{unlinked_count} accounts need setup...") if sync.respond_to?(:status_text) else🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/models/brex_item/syncer.rb` around lines 22 - 31, Compute and cache the counts for linked_accounts and unlinked_accounts once instead of calling any? and count separately: fetch brex_item.brex_accounts.joins(:account_provider).count into a variable (e.g., linked_count) and brex_item.brex_accounts.left_joins(:account_provider).where(account_providers: { id: nil }).count into unlinked_count, then use those cached integers for the branching (if unlinked_count > 0 / else) and for sync.update!(status_text: "#{unlinked_count} accounts need setup...") as well as for the linked_accounts logic; update pending_account_setup on brex_item and status_text on sync using these counts to avoid duplicate DB queries.
28-28: ⚡ Quick wini18n: hardcoded user-facing status strings.
Status text strings like
"Importing accounts from Brex...","Checking account configuration...","#{unlinked_accounts.count} accounts need setup...","Processing transactions...", and"Calculating balances..."are hardcoded throughoutperform_sync. These are user-visible (rendered in the UI viasync.status_text) and should go throughI18n.tto match the localization pattern used elsewhere in the file (e.g.,user_safe_error_messagealready usesI18n.t("brex_items.syncer.*")).As per coding guidelines: "Always use
t()helper for user-facing strings."🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/models/brex_item/syncer.rb` at line 28, The hardcoded user-facing status strings in perform_sync (e.g., the arguments passed to sync.update!(status_text: ...)) must be replaced with I18n lookup calls; locate perform_sync in BrexItem::Syncer and replace each literal like "Importing accounts from Brex...", "Checking account configuration...", "#{unlinked_accounts.count} accounts need setup...", "Processing transactions...", and "Calculating balances..." with t(...) calls (e.g., t("brex_items.syncer.importing_accounts") etc.), passing dynamic values (like unlinked_accounts.count) as interpolation locals to I18n and ensure the same translation key namespace used by user_safe_error_message is used so the UI-texts are localizable.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@app/views/brex_items/select_existing_account.html.erb`:
- Around line 48-50: Add parameterized tests that mirror the existing return_to
validation coverage for select_accounts but target the select_existing_account
action: test that unsafe return_to values (protocol-relative URLs like //evil,
backslash-containing paths like \evil, percent-encoded hosts, whitespace-only
values, and a solitary "/") are rejected and that safe_return_to_path
sanitization is applied; use the same test helper/approach as the
select_accounts suite to assert the response redirects to a safe fallback
(`@return_to` or accounts_path) and does not permit external hosts or malformed
paths, referencing the select_existing_account controller action and the
safe_return_to_path behavior in your assertions.
In `@db/migrate/20260505010000_create_brex_items_and_accounts.rb`:
- Around line 5-28: The migration should enforce non-null at the DB level for
brex_items.name and brex_items.token: modify the create_table block (brex_items)
to declare t.string :name, null: false and t.text :token, null: false; if this
migration has already been run in production instead create a new migration that
uses change_column_null :brex_items, :name, false and change_column_null
:brex_items, :token, false (and ensure any existing NULLs are backfilled before
flipping the constraint).
---
Nitpick comments:
In `@app/models/brex_item.rb`:
- Around line 86-88: BrexItem currently stores raw_payload plaintext in
upsert_brex_snapshot! while BrexAccount declares encrypts :raw_payload; to fix,
add symmetric encryption on the parent model by declaring encrypts :raw_payload
in the BrexItem model (so BrexItem.upsert_brex_snapshot! continues to call
BrexAccount.sanitize_payload but the stored snapshot is encrypted at rest), or
if intentional, add an explicit comment in BrexItem documenting why raw_payload
is stored unencrypted and reference BrexAccount.sanitize_payload and the threat
model.
In `@app/models/brex_item/account_flow.rb`:
- Around line 67-83: Replace the two cache lookups by doing a single
Rails.cache.read(cache_key) inside preload_payload: call cached_value =
Rails.cache.read(cache_key), set cached = !cached_value.nil? (or
cached_value.present? if you want to treat empty arrays as not-cached), then set
available_accounts = cached_value || accounts (so accounts is only called when
the cache miss happens). Keep using the existing accounts helper for the
fetch/path that writes the cache; update the payload to return cached based on
the single read result.
In `@app/models/brex_item/syncer.rb`:
- Line 50: The N+1 stems from calling ma.current_account on each linked_account
while only preloading :account_provider; update the preload to include the
through association so current_account is eager loaded (e.g. change the includes
call on linked_accounts from includes(:account_provider) to include the account
via the provider such as includes(account_provider: :account) or the direct
association name if defined), then keep the filter_map { |ma|
ma.current_account&.id } unchanged so no extra queries are issued.
- Around line 22-31: Compute and cache the counts for linked_accounts and
unlinked_accounts once instead of calling any? and count separately: fetch
brex_item.brex_accounts.joins(:account_provider).count into a variable (e.g.,
linked_count) and
brex_item.brex_accounts.left_joins(:account_provider).where(account_providers: {
id: nil }).count into unlinked_count, then use those cached integers for the
branching (if unlinked_count > 0 / else) and for sync.update!(status_text:
"#{unlinked_count} accounts need setup...") as well as for the linked_accounts
logic; update pending_account_setup on brex_item and status_text on sync using
these counts to avoid duplicate DB queries.
- Line 28: The hardcoded user-facing status strings in perform_sync (e.g., the
arguments passed to sync.update!(status_text: ...)) must be replaced with I18n
lookup calls; locate perform_sync in BrexItem::Syncer and replace each literal
like "Importing accounts from Brex...", "Checking account configuration...",
"#{unlinked_accounts.count} accounts need setup...", "Processing
transactions...", and "Calculating balances..." with t(...) calls (e.g.,
t("brex_items.syncer.importing_accounts") etc.), passing dynamic values (like
unlinked_accounts.count) as interpolation locals to I18n and ensure the same
translation key namespace used by user_safe_error_message is used so the
UI-texts are localizable.
In `@app/views/brex_items/select_existing_account.html.erb`:
- Line 24: Replace the raw color token bg-red-tint-5 used in the label class
conditional with a functional surface token (e.g., bg-destructive-subtle or
bg-surface/bg-container) so the destructive state uses design-system semantics;
update the conditional in the label for account_display.blank_name? inside
select_existing_account.html.erb (and the identical spot in
select_accounts.html.erb) to use the chosen functional utility (e.g., change
"bg-red-tint-5" to "bg-destructive-subtle" or "bg-surface") and if your design
system lacks a destructive surface token, add a new utility
(bg-destructive-subtle) to sure-design-system.css and use that class in the
label conditional.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 52be0c02-c9f4-4537-8974-90311609657e
📒 Files selected for processing (23)
app/controllers/brex_items/account_flows_controller.rbapp/controllers/brex_items/account_setups_controller.rbapp/models/brex_account.rbapp/models/brex_entry/processor.rbapp/models/brex_item.rbapp/models/brex_item/account_flow.rbapp/models/brex_item/syncer.rbapp/models/provider/brex_adapter.rbapp/views/accounts/index.html.erbapp/views/brex_items/_brex_item.html.erbapp/views/brex_items/select_accounts.html.erbapp/views/brex_items/select_existing_account.html.erbconfig/locales/views/brex_items/en.ymldb/migrate/20260505010000_create_brex_items_and_accounts.rbdb/schema.rbtest/controllers/brex_items_controller_test.rbtest/models/brex_account/transactions/processor_test.rbtest/models/brex_account_test.rbtest/models/brex_entry/processor_test.rbtest/models/brex_item/account_flow_test.rbtest/models/brex_item/importer_test.rbtest/models/brex_item/syncer_test.rbtest/models/brex_item_test.rb
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (3)
app/models/brex_item/account_flow.rb (2)
297-347: ⚡ Quick winN+1 in
complete_setup!— preload brex_accounts with theiraccount_providerassociation.The loop issues two queries per submitted account (one
find_by+ oneaccount_provider.present?association load), totalling 2N queries. Preloading avoids this:♻️ Proposed refactor
def complete_setup!(account_types:, account_subtypes:) created_accounts = [] skipped_count = 0 valid_types = Provider::BrexAdapter.supported_account_types failed_count = 0 + brex_accounts_by_id = brex_item.brex_accounts + .includes(:account_provider) + .where(id: account_types.keys) + .index_by { |a| a.id.to_s } account_types.each do |brex_account_id, selected_type| ... - brex_account = brex_item.brex_accounts.find_by(id: brex_account_id) + brex_account = brex_accounts_by_id[brex_account_id.to_s] unless brex_account🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/models/brex_item/account_flow.rb` around lines 297 - 347, The loop in complete_setup! is causing an N+1 by calling brex_item.brex_accounts.find_by(...) and then checking brex_account.account_provider.present? for each entry; preload the brex_accounts with their account_provider association once before iterating (e.g., load the submitted accounts via brex_item.brex_accounts.where(id: account_types.keys).includes(:account_provider) and index them by id) and then use that in the loop instead of calling find_by each time so the account_provider check does not fire extra queries.
106-116: ⚡ Quick win
link_new_accounts_resultandlink_existing_account_resultleaveStandardErrorunhandled — inconsistent withselection_result_for.Both write-path methods rescue only known domain errors. An unexpected
ActiveRecord::RecordInvalid(e.g., fromAccount.create_and_syncorAccountProvider.create!) propagates uncaught through these methods to the controller, resulting in a 500 rather than a user-facing alert.selection_result_for(Line 444) already rescuesStandardError—the same defensive pattern should be applied here for consistency.♻️ Proposed fix
def link_new_accounts_result(account_ids:, accountable_type:) ... link_navigation_result(link_new_accounts!(account_ids: account_ids, accountable_type: accountable_type)) rescue NoApiTokenError navigation(:new_account, :alert, I18n.t("brex_items.link_accounts.no_api_token")) rescue Provider::Brex::BrexError => e navigation(:new_account, :alert, I18n.t("brex_items.link_accounts.api_error", message: e.message)) + rescue StandardError => e + Rails.logger.error("Unexpected error linking Brex accounts: #{e.class}: #{e.message}") + navigation(:new_account, :alert, I18n.t("brex_items.errors.unexpected_error")) end def link_existing_account_result(account:, brex_account_id:) ... rescue Provider::Brex::BrexError => e navigation(:accounts, :alert, I18n.t("brex_items.link_existing_account.api_error", message: e.message)) + rescue StandardError => e + Rails.logger.error("Unexpected error linking existing Brex account: #{e.class}: #{e.message}") + navigation(:accounts, :alert, I18n.t("brex_items.errors.unexpected_error")) endAlso applies to: 118-136
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/models/brex_item/account_flow.rb` around lines 106 - 116, The methods link_new_accounts_result and link_existing_account_result currently only rescue domain errors and let unexpected exceptions (e.g., ActiveRecord::RecordInvalid) bubble up; add a catch-all rescue StandardError (as selection_result_for does) to each method so unexpected errors are converted to a user-facing navigation alert (use the same I18n key/pattern used for API errors and include the exception message), ensuring any ActiveRecord::RecordInvalid or other StandardError gets handled and returns a navigation(...) instead of raising to the controller.test/controllers/brex_items_controller_test.rb (1)
141-142: ⚡ Quick winAssert translated flash copy via I18n instead of English literals.
These assertions are checking user-facing copy, so harmless translation or wording updates will fail the suite even when controller behavior is unchanged. Prefer
I18n.t(...)in the expectations.As per coding guidelines "Always use
t()helper for user-facing strings."Also applies to: 268-268, 337-338, 370-370, 383-383, 430-430
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@test/controllers/brex_items_controller_test.rb` around lines 141 - 142, Change assertions that compare flash text to hard-coded English to use the I18n helper instead; e.g. replace assert_equal "Choose a Brex connection in Provider Settings.", flash[:alert] with assert_equal t('choose_brex_connection_in_provider_settings', default: "Choose a Brex connection in Provider Settings."), flash[:alert] (and do the same replacement for the other occurrences called out). Use t(...) (or I18n.t(...)) with an appropriate translation key and a default that matches the current English copy so tests remain stable while allowing future translations.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@app/models/brex_item.rb`:
- Around line 13-17: The model currently strips whitespace in normalize_token
before validation, allowing persisted records to accept updates like
update!(token: " ") because presence: true is only enforced on :create; add a
model-level validation that prevents blanking the token on updates by checking
for token changes and rejecting blank values: implement a custom validation
method (e.g., token_cannot_be_blank_when_changed) that runs after
normalize_token and calls errors.add(:token, "...") when token_changed? &&
token.blank? (retain the existing validates :token, presence: true, on: :create
and the before_validation :normalize_token hook).
In `@app/models/brex_item/syncer.rb`:
- Around line 15-16: perform_sync is marking syncs healthy unconditionally
because it ignores return values from BrexItem::Importer#import
(brex_item.import_latest_brex_data), BrexItem#process_accounts and
BrexItem#schedule_account_syncs which can return success: false or per-item
failures; update perform_sync to capture and aggregate those results (check the
return value of brex_item.import_latest_brex_data and the hashes/arrays returned
by process_accounts and schedule_account_syncs), convert failures into an errors
array or boolean, and pass that into collect_health_stats(sync, errors: ...) so
partial/complete failures are propagated before marking the sync healthy.
---
Nitpick comments:
In `@app/models/brex_item/account_flow.rb`:
- Around line 297-347: The loop in complete_setup! is causing an N+1 by calling
brex_item.brex_accounts.find_by(...) and then checking
brex_account.account_provider.present? for each entry; preload the brex_accounts
with their account_provider association once before iterating (e.g., load the
submitted accounts via brex_item.brex_accounts.where(id:
account_types.keys).includes(:account_provider) and index them by id) and then
use that in the loop instead of calling find_by each time so the
account_provider check does not fire extra queries.
- Around line 106-116: The methods link_new_accounts_result and
link_existing_account_result currently only rescue domain errors and let
unexpected exceptions (e.g., ActiveRecord::RecordInvalid) bubble up; add a
catch-all rescue StandardError (as selection_result_for does) to each method so
unexpected errors are converted to a user-facing navigation alert (use the same
I18n key/pattern used for API errors and include the exception message),
ensuring any ActiveRecord::RecordInvalid or other StandardError gets handled and
returns a navigation(...) instead of raising to the controller.
In `@test/controllers/brex_items_controller_test.rb`:
- Around line 141-142: Change assertions that compare flash text to hard-coded
English to use the I18n helper instead; e.g. replace assert_equal "Choose a Brex
connection in Provider Settings.", flash[:alert] with assert_equal
t('choose_brex_connection_in_provider_settings', default: "Choose a Brex
connection in Provider Settings."), flash[:alert] (and do the same replacement
for the other occurrences called out). Use t(...) (or I18n.t(...)) with an
appropriate translation key and a default that matches the current English copy
so tests remain stable while allowing future translations.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: afbea875-0d41-4575-96df-fc1149c29e98
📒 Files selected for processing (14)
app/assets/tailwind/sure-design-system/_generated.cssapp/models/brex_item.rbapp/models/brex_item/account_flow.rbapp/models/brex_item/syncer.rbapp/views/brex_items/select_accounts.html.erbapp/views/brex_items/select_existing_account.html.erbconfig/locales/views/brex_items/en.ymldb/migrate/20260505010000_create_brex_items_and_accounts.rbdb/schema.rbdesign/tokens/sure.tokens.jsontest/controllers/brex_items_controller_test.rbtest/models/brex_item/account_flow_test.rbtest/models/brex_item/syncer_test.rbtest/models/brex_item_test.rb
|
(apologies for the bot/AI slop noise, removing some comments here and in other PRs as wel speak ...) |
|
Merge from main received (May 6). jjmata's review raised two items that need addressing before this can move forward:
Also: there's still no explicit confirmation from @jjmata that Brex is on the product roadmap. Getting that answer first will avoid wasted review cycles if the answer is no. Code review note from Claude Code Generated by Claude Code |
I've addressed these issues as well. |
|
New commits pushed 2026-05-07. A few issues that should be resolved before this enters formal review:
Recommend either returning to draft until the design token question is resolved with @sure-design, or splitting out the design token change as a prerequisite. Generated by Claude Code Generated by Claude Code |
Signed-off-by: Juan José Mata <[email protected]>
|
We either merge this real quick before #1710 lands or I foresee a lot of conflicts, @JSONbored? Actually, can you merge that branch into yours locally just to see how bad it will be? |
Clarified the status of the Maybe Finance team and the repository. Signed-off-by: Juan José Mata <[email protected]>
* Add manual Sophtron sync flow (maybe-finance#1705) Branch-to-branch merge. * Copy edits * Make Sophtron manual sync institution scoped * Populate Sophtron manual sync stats * Restore Sophtron bank credential copy * Address Sophtron manual sync review feedback * Scope manual sync processing failure handling * Hide raw Sophtron processor errors from flash * Clear Sophtron manual sync pointers on provider errors * Keep manual Sophtron MFA on manual sync records * Preserve manual sync processing error details
…inance#1732) AGENTS.md already nails API-endpoint consistency for backend work but says nothing about UI conventions. Result: contributor PRs and review agents repeatedly flag the same shape — raw Tailwind palette where a semantic token exists, hand-rolled alert / button / disclosure blocks when DS::* already covers them — and the rule has to be re-discovered in review every time. Adds a short Design-System-Hygiene block (token use, 'reach for DS::* first', the 'two copies => lift to DS' threshold, and the icon helper / t() conventions) so the rule lives next to the API one and reviewers have something to cite. Mirrors the weight of the API section, links the DS-consolidation umbrella issue (#1715). Refs #1715
…blocks (maybe-finance#1731) * feat(design-system): add info semantic color token Mirrors success/warning/destructive: --color-info maps to blue-600 in light mode, blue-500 in dark mode. Unblocks the DS::Alert info variant from carrying a raw 'blue-600' literal in icon_color and lets surface tokens use bg-info/N alpha modifiers like the rest of the system. Refs #1715 * refactor(design-system): adopt semantic tokens and add body slot in DS::Alert Replaces the bg-{blue,green,yellow,red}-50 / text-{...}-700 / border-{...}-200 palette block in DS::Alert with semantic alpha-modifier surfaces (bg-{info,success,warning,destructive}/10 + matching /20 borders). Drops the 'blue-600' literal that icon_color was returning for the info variant; helpers#icon now accepts color: :info backed by the new --color-info token. Adds an optional title: kwarg and an opt-in block-content slot so rich alerts (title + paragraph, lists, embedded actions) can render without callers reaching for a hand-rolled flex layout. The existing message: API stays backward-compatible — nothing in the codebase that already calls DS::Alert.new(message: ..., variant: ...) needs to change. Lookbook gains with_title and with_body_slot examples covering the new shapes. Refs #1715 * refactor(views): migrate api_keys, hostings, lunchflow alerts to DS::Alert Cleans up nine bespoke alert blocks that hand-rolled the same flex + icon + bordered-surface shape DS::Alert already provides: - settings/api_keys/{new,created,created.turbo_stream}.html.erb — three near-identical 'Security Warning' / 'Important Security Note' boxes using the broken bg-warning-50 / text-warning-700 raw-palette pair. - settings/hostings/{_alpha_vantage,_eodhd,_yahoo_finance,_twelve_data,_provider_selection}_settings.html.erb — five amber-50 / amber-200 warning boxes covering rate-limit notes, health-check failure messaging, and the env-configured override banner. The twelve_data plan-restriction block keeps its bullet list and pricing link inside the new DS::Alert body slot. - lunchflow_items/{_api_error,_setup_required}.html.erb — two modal alert headers whose flex+icon scaffolding now collapses onto DS::Alert. The surrounding bg-surface 'Common issues' / 'Setup steps' info cards stay as-is; this PR only touches the alert shape itself. No functional or behavioural changes. Locale keys preserved. amber-* palette uses on the alerts disappear; remaining bg-amber-* hits in the codebase live outside the alert pattern and stay for follow-up sub-PRs of #1715. Refs #1715
* i18n(es): fill small high-value locale gaps * Small tweak --------- Co-authored-by: KiloClaw <[email protected]> Co-authored-by: Juan José Mata <[email protected]>
* feat(settings/providers): surface connection status in section headers Lifts the per-panel status indicator up to each collapsed accordion header so admins can see at a glance which providers are connected without expanding every section. Connected providers sort first. - Add optional status: and meta: locals to settings/_section partial; pill hides via group-open:hidden when the section is expanded - New settings/providers/_status_pill partial (ok/warn/err/off states) - Add SettingsHelper#provider_summary to centralise the connected-vs-not logic already scattered across panel partials - Refactor show.html.erb to pass status to every section and sort family_panels by connection state - Add settings.providers.status.* i18n keys - Add system tests asserting pill renders and sort order https://claude.ai/code/session_01KW2HCN9rP1fiyQuw7Cju9D * feat(settings/providers): group providers into Connected and Available Partition the provider list in the controller into @connected_providers and @available_providers based on provider_summary status, and render each group under its own heading with a count. Auto-open the section when only one provider is connected. Adds an empty-state line when nothing is connected yet. Co-Authored-By: Claude Opus 4.7 <[email protected]> * feat(settings/providers): health strip, action-needed group, and sync error surfacing - Extend provider_summary to return :err/:warn with meta text by checking latest sync per item (window function, same pattern as ProviderConnectionStatus) and Enable Banking session expiry within 7 days - Partition provider entries into three groups: Connected (:ok), Action needed (:warn/:err, auto-opened), Available (:off) - Add Settings::HealthSummary ViewComponent — four-tile grid showing Connected, Action needed, Errors, and Accounts synced counts - Render health strip directly under page description; omit Action needed heading when group is empty - Add i18n keys for tile labels, group heading, and all meta strings Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * feat(settings/providers): card grid for available providers with connect drawer - Add Provider::Metadata registry with static display data (region, kind, tier, maturity, logo) for all 11 providers - Add Settings::ProviderCard ViewComponent rendering logo square, name, Beta/Alpha pill, meta line (region · type · tier), tagline, and Connect link - Add connect_form action + route (GET /settings/providers/:key/connect_form) that opens the existing panel partial or config form in a DS::Dialog drawer - Replace the Available accordion loop with a 2-column responsive card grid; empty state when all providers are connected - Fix layout override: use turbo_rails/frame layout for frame requests so the drawer response is not wrapped in the full settings layout (was causing Turbo to pick the empty outer drawer frame instead of the filled one) - Add SyncAllProvidersJob and last_sync_all_attempted_at migration (sync-all throttle support) - Unify Connected + Action needed into a single "Your connections" section; items with warn/err status auto-open - Fix Enable Banking grouping: items with expired sessions were returning :off (Available) instead of :warn (Your connections); gate now checks any? instead of any?(&:session_valid?) - Add reconsent_required locale key for fully-expired EB sessions - Surface Beta/Alpha maturity pills on connected provider accordion rows via new badge: param on settings_section helper - Add i18n taglines for all 11 providers; add connect and empty_available keys Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * feat(settings): retire /settings/bank_sync; merge into providers page - Delete Settings::BankSyncController and its views (the providers page is now a strict superset of what bank_sync offered) - Add permanent 301 redirect: GET /settings/bank_sync → /settings/providers - Collapse nav to a single "Bank Sync" entry pointing at /settings/providers; remove the duplicate admin-only "Providers" entry from the Advanced section - Remove "Providers" from SETTINGS_ORDER; point "Bank Sync" at settings_providers_path for next/prev navigation - Rename page title to "Bank Sync"; replace admin-credential lede with user-facing copy ("Connect external accounts…") - Update breadcrumb: Home → Bank sync - Add controller test asserting 301 status and Location header Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * Migrations are 7.2 here * Minimize schema noise * Schema duplication * Small copy edits * Fix tests * Address provider settings review feedback * refactor(settings/providers): finish design-review cleanup pass Picks up the remaining items from Claude Design's review of maybe-finance#1710 that the previous review-feedback commit didn't cover. DS / casing - Sentence-case the page title ("Bank Sync" -> "Bank sync") and align the nav label. - Drop the card hover-lift (shadow-border-sm) in favour of bg-container-hover; per the DS, card hover is colour-only. - Whole-tile click target on each provider card — the inner "Connect ->" link was a hit-target inversion. - Set Sync all to whitespace-nowrap so the label stops wrapping at narrow viewport widths. UX simplifications - Drop the four health-summary tiles (per-row warn/err pills already surface the signal at the scale this app sees). Removes Settings::HealthSummary, the @health_counts controller block, and the now-unused health.* locale keys. - Hide "Your connections" heading + empty-state line when no providers are connected — the lede already invites a connect. - Drop the redundant "Free" tier from per-card meta lines (printed 10x for one fact); "Paid" still surfaces on Plaid. Tests updated to drop the obsolete tiles assertion and switch the provider-card click selector to look up the (now whole-card) anchor by provider name. * feat(settings/providers): replace Add another provider CTA with a search + kind filter Per the design review, the "Add another provider · Browse providers" card was a redirect to content one scroll-tick away. A search input plus kind chips lets users self-segment the catalog and is the right tool once it grows beyond the four to twelve providers we ship today. - New providers_filter Stimulus controller — case-insensitive free text search across name/region/kind, plus a chip group with All / Banks / Crypto / Investment that toggle visibility via Tailwind's `hidden` class. - _search_filters partial: search box (count-pluralized placeholder) + chip group, ARIA-labelled and aria-pressed for the chips. - ProviderCard exposes filter_data (target + name/region/kind data attrs) so the controller can match without re-rendering. - Lunchflow's `kind` was "Lunch" — switched to "Bank" so it falls under the Banks chip alongside its actual offering (it aggregates banks). - Drops the add_provider_cta partial and its locale entries; adds search_filters.* and an empty_filter message. * Private method fix * refactor(settings/providers): drawer cleanup, header lock-up, trust statement Per the design review's §07. - Drop the trailing "Configured / Not configured" footer status from every provider panel (binance, coinbase, coinstats, indexa_capital, lunchflow, mercury, simplefin, snaptrade, sophtron, provider_form). The parent details section's status pill already carries that signal; the footer was redundant — and the copy/styling was inconsistent across panels (free-text vs. dot pill, "configured" vs. "not connected"). - Connect drawer gets a header lock-up: small logo chip + provider name + maturity badge, mirroring the available-card layout. Implemented as _drawer_header partial; connect_form passes custom_header: true to DS::Dialog so we own the row. - Drawer footer trust statement: "Read-only — Sure can never move money. Stored encrypted." A single-line reassurance covering all panels. - Sentence-case the hardcoded primary buttons that were Title Case: "Save Configuration" -> "Save and connect" "Update Configuration" -> "Update connection" "Connect Bank" -> "Connect bank" Affects simplefin, lunchflow, enable_banking, provider_form. The i18n'd panels (binance, coinbase, coinstats, indexa_capital, mercury, snaptrade, sophtron) keep their existing keys. * chore(locales): drop unused provider-panel status strings Footer "Configured / Not configured" status was removed from each provider panel partial in the prior drawer-cleanup pass; the matching i18n keys are no longer referenced. Removing them across every locale to keep the catalogue clean. Dropped (15 keys × varying locale coverage, 36 line removals across 24 files): - coinstats_items.new.{status_configured_html, status_not_configured} - indexa_capital_items.panel.{status_configured_html, status_not_configured} - mercury_items.provider_panel.{configured_html, not_configured, accounts_link} - sophtron_items.sophtron_panel.status.{configured_html, not_configured} (parent `status:` removed where it became empty) - providers.snaptrade.{status_needs_registration, status_not_configured} (status_connected stays — still used by the lazy-load summary) - settings.providers.{binance_panel, coinbase_panel}.{status_connected, status_not_connected} * feat(settings/providers): connected-state polish per design §05 + Linked institutions rename Building the next phase of the design review. Pulls forward the slim health strip, denser connection rows, and "Linked institutions" heading rename — the small Phase A lift the designer flagged in §08 of the doc. - New _health_strip partial: single-line at-a-glance pulse — connected count + needs-attention count + accounts syncing + last-synced timestamp. Renders only when at least one provider is linked or needs action. - New _connection_row partial replaces the generic settings_section call for providers. Tighter rows: text-sm title (was text-lg), px-4 py-3.5 padding, single-line summary (chevron + name + maturity badge + meta + status pill + sync action). Warn/error rows get a coloured outline (border-warning/25 or border-destructive/25) so the at-risk row stands out without shouting. - "Sync all" button restyled to match the design's secondary button: text-primary, alpha-black-100 border, rounded-[10px], padding 7px 12px (was the broader px-3 py-1.5 ghost). - "Your connections" → "Linked institutions" heading, lifted from the designer's Phase-C reconciliation note. Primes users for the Option-C institution-search wizard six months early; existing i18n key stays as `groups.your_connections` for now to keep the rename to a single value flip. - Controller computes the new @health hash (connected, needs_attention, accounts_syncing, last_synced_at) feeding the strip; brings back the single accounts query that was removed with the four-tile component. System test updated for the new heading copy. * fix(settings/providers): align connected state with the final design mock Tightening the §05 polish to match the user-confirmed final design. - Revert "Linked institutions" → "Your connections". The §08 designer note about the Phase-A heading rename didn't carry forward to the final mock; keep the original wording. - Drop the warn/err auto-open on connection rows. The design shows Enable Banking collapsed with a warn-outline and a status pill — no auto-expanded form. Single-connection auto-open kept (handy when the page is otherwise empty). - Hide the "accounts syncing" segment in the health strip when the count is 0 — the design mock assumes a populated number; an always-visible "0 accounts syncing" reads as a placeholder. - Strip the leading "about " from `time_ago_in_words` everywhere the result is shown to the user (health strip "Last synced %{time} ago" plus per-row "Synced %{time} ago" meta). Matches the design's shorter copy. * refactor(settings/providers): tighten paddings, dedupe maturity badge, semantic + a11y fixes Pixel-level alignment to the design's §05 mock + cleanup from a DS audit pass. Paddings, margins, font sizes - Health strip: my-4 → mt-4 mb-5 to match the design's 16px / 20px vertical breathing room. - Search filters bar: gap-2 → gap-2.5; mt-2 → mt-5 mb-3 (was missing the 12px bottom margin entirely). - Search box: rounded-lg → rounded-[10px]; px-3 py-2 → px-[14px] py-[9px]. Search icon downsized w-4 → w-3.5 to match. - Chip group: p-1 → p-[3px]; rounded-lg → rounded-[10px]. - Chip: py-1 → py-[5px]; rounded-md → rounded-lg. - Group heading: mt-2 → mt-[18px]; mb-1 → mb-1.5. - Status pill: text-xs → text-[11px]. - Provider card: gap-3 → gap-2.5 (outer + top); name gets explicit text-sm; tagline + foot 14px → 13px; arrow icon w-4 → w-3.5. - Sync icon button: p-1 → fixed w-7 h-7 (28×28) so the row hit target matches the design's column width. - Connect drawer header logo glyph: text-[10px] → text-xs (matches the available card's logo-glyph treatment). Component / partial cleanup (DS audit follow-ups) - New _maturity_badge partial replaces the inline span that was duplicated in 3 places (_connection_row, _drawer_header, provider_card.html.erb). - Settings::ProviderCard.maturity_label class method centralizes the MATURITY_LABELS lookup; callers no longer reach into the constant. - _connection_row title: <h2> → <h3> (the row sits inside the "Your connections" h2 group heading; nested h2s flattened the outline). - show.html.erb encryption error: <h3> → <h2> for the same reason. Locale - Drop orphaned keys: settings.providers.groups.connected and groups.needs_attention (no view code uses them) plus the leftover show.coinbase_title block. - Health strip "needs reconsent" → "needs attention" so the strip copy lines up with the per-row status pill ("Action needed") and the original group heading wording. A11y - focus-visible:ring-2 on chip buttons, provider-card link, and focus-within:ring-2 on the search input wrapper. Keyboard users now get a visible focus state. - Search input: explicit autocomplete="off" (erb_lint hint). * fix(settings/providers): icons + search input height - Icons were rendering at 20px because the application_helper's `icon` default size (`md` = w-5 h-5) was beating the inline class override in compiled CSS source order. Pass `size: "sm"` and use the project's `!w-3.5 !h-3.5` important-prefix pattern (precedent: dashboard.html.erb) so chevron, refresh-cw, search, check, circle-alert, and arrow-right all render at the design's 14px. - Search input was 54px tall because @tailwindcss/forms applies `padding: 8px 12px` to bare `<input type="search">`. Override with `!p-0 focus:ring-0 focus:shadow-none` so the wrapping div's padding alone defines the box (38px total — matches the design). * refactor(settings/providers): align Sync all + search input with DS, address review feedback - Sync all: replace the hand-rolled `button_to` with `DS::Link.new(variant: "outline", method: :post)` — same component as the "Identify Patterns" button on the recurring-transactions page. - Search input: switch to the icon-overlay pattern used by the Manage-currencies and transaction filter rows (relative wrapper + absolutely positioned search icon + bordered input with `focus:ring-gray-500`). Brings the keyboard focus state in line with the rest of the app's filterable lists. - SnapTrade panel: restore the "needs registration" status row that the drawer-cleanup pass dropped along with the redundant Configured/Not configured footer. The unregistered case is meaningful state, not redundant chrome. - Move the slim health-strip computation out of the controller and into `SettingsHelper#provider_health_strip` (Convention 2: skinny controllers). - Extract `concise_time_ago` helper so the "drop leading 'about '" trick stops being duplicated 3x. - `Settings::ProviderCard#maturity_label` (instance) now delegates to `.maturity_label` (class) instead of duplicating the lookup. - Drop unused `warn_or_err` local in `_connection_row`. - Replace the `data-controller` string-injection + html_safe in `_connection_row` with `tag.details(data: ...)`; safer and more idiomatic. - Add a system test for the empty-filter message wiring. * fix(settings/providers): drawer trust statement uses border-tertiary `border-secondary/10` was reaching for the text-foreground token at 10% opacity for a divider. The project ships a dedicated divider token (`border-tertiary`, ~8% black) used by DS::Menu, the holdings page, and admin/sso forms. Switching to it makes the trust-statement HR match every other thin divider in Sure and stops misusing the text token as a border. * refactor(settings/providers): swap arbitrary Tailwind values for scale tokens Per the user's directive — DS-compliance over pixel-perfect alignment with the design mock. Walked the design audit and applied every swap that lands within ±2px of the original. Swaps: - _health_strip: gap-[18px] → gap-5 (+2), px-[14px] → px-3.5 (=), text-[13px] → text-sm (+1). - _search_filters: chip group p-[3px] → p-1, rounded-[10px] → rounded-xl (concentric with rounded-lg inner pills), chip py-[5px] → py-1. - _status_pill: text-[11px] → text-xs. - _group_heading: mt-[18px] → mt-5. - _maturity_badge: text-[10px] → text-xs. - provider_card: tagline + foot text-[13px] → text-sm. Kept arbitrary: `min-w-[200px]` in _search_filters — nearest scale tokens are min-w-48 (192px) and min-w-52 (208px); both are noticeable layout shifts for a one-off responsive guard. Worth keeping the arbitrary here. Net: 9 of 10 arbitrary values gone. Visual delta: max +2px on a single value. Design mock and DS scale now agree. * revert(settings/providers): drop the slim health strip Per-row status pills already carry the at-a-glance signal (connected / action needed) at the scale this app sees (1–4 connections per family). The strip was redundant chrome for almost every user; only worth bringing back if the catalog grows to a point where the row list itself stops fitting on a single screen. - Delete _health_strip.html.erb partial. - Drop @health controller assignment + provider_health_strip helper. - Drop unused settings.providers.health_strip.* locale keys. - concise_time_ago helper stays — still used by per-row meta text. * refactor(settings/providers): align with DS conventions Two consistency wins from the screenshot/DS audit pass. Sync icon button now renders DS::Button (variant: icon, size: sm) instead of a hand-rolled `button_to`. Same component used by other icon-only actions across the app (settings/profiles, layouts/imports). Visual delta: 28×28 → 32×32 (DS sm size). Accept the +4px for consistency. `event.stopPropagation()` still wired via the form opt so the row's <details> doesn't toggle when the user clicks the button. Group heading now follows the established Sure section-label style (`text-xs font-medium text-secondary uppercase`) used by `_settings_nav` and the imports/categories surfaces. The previous sentence-case `text-sm text-primary` was a one-off that didn't match the rest of the app. Locale strings stay sentence-case; uppercase comes from CSS `text-transform`. Tests updated to case-insensitively match the rendered heading text. * fix(provider/metadata): add plaid_eu entry `plaid_eu` is registered as a separate Provider::ConfigurationRegistry entry but had no Provider::Metadata row, so its card in the Available grid fell through to the gray-500 default and rendered empty (no region, kind, tier, or tagline). The title also came out as "Plaid Eu" because `titleize` doesn't know "EU" is an initialism. - Add a `plaid_eu` row to Provider::Metadata::REGISTRY with the same shape as `plaid` (US → EU, otherwise identical). - Introduce an optional `name:` field in metadata; controller falls back to it before titleizing the provider key. Lets `plaid_eu` render as "Plaid EU". - Add the missing `settings.providers.taglines.plaid_eu` translation. * fix(settings/providers): center-align Sync all next to the lede `items-start` made the button hug the first line when the lede wrapped; on a single line the button sat at the top of the text bounding box which read slightly off. Center matches the dominant convention across the rest of settings (api_keys, securities, hostings, _section, _settings_nav_link_large). * fix(settings/providers): drop colour palette + filter polish + drawer warnings Round of design-feedback fixes. Provider chips - Drop the per-provider raw Tailwind palette (bg-blue-600 etc.) from Provider::Metadata. All cards + drawer logo lock-up now use bg-surface-inset + text-primary, matching the design's §04 "drop colour entirely" recommendation. Solves the long-standing §01 BLOCKER without externalising brand assets. Re-introducing logos later just means an optional logo_svg: field on metadata. - ProviderCard component drops the `logo_bg:` parameter; the chip is now styled in the template. Filter / search - "Available · N" count and the empty-filter state now update client-side as the chip filter and free-text search narrow the grid (new `count` Stimulus target + dedicated update path). - Empty-filter state now offers a Clear filters button that resets both the search input and the active chip in one click. - Search placeholder drops the drifting "Search 9 providers" count for plain "Search providers" — the section heading carries the number. - Chip labels normalised to plural where natural: "Banks · Crypto · Investments" (Crypto stays as the mass noun). Drawer copy / treatment - "IP Whitelisting Required" → "IP whitelisting required" (DS sentence-case). - Binance "do NOT enable withdrawal permissions" lifted out of inline red-text into a proper bg-warning-50 border-warning-200 alert block with an alert-triangle icon. Matches the api_keys / hosting alert pattern. - SnapTrade free-tier inline alert-triangle now uses `size: "sm"` so the icon stops rendering at 20px next to 14px body text. Spacing - Group-heading margin top bumped 5 → 6 (20→24px) so the eyebrow has more breathing room above the search bar. * refactor(settings/providers): drawer alerts use DS::Alert; drop card-in-card Two consistency fixes from a design-review pass. DS::Alert adoption - Replaces 9 hand-rolled error blocks across the provider panels (`bg-destructive/10 text-destructive ... line-clamp-3`) with `DS::Alert(variant: :error)` — the project's existing primitive. - Replaces the just-shipped Binance no-withdraw warning block with `DS::Alert(variant: :warning)` instead of a hand-rolled `bg-warning-50 border-warning-200` card. - Replaces the SnapTrade free-tier inline icon-prefixed warning paragraph with `DS::Alert(variant: :warning)` — proper alert treatment for an actual warning, not body copy. - Replaces the Enable Banking "Configuration locked" inline `bg-warning/10` two-paragraph block with `DS::Alert(variant: :warning)` using `safe_join` for the title + body. - Replaces the encryption-error block at the top of show.html.erb with `DS::Alert(variant: :error)`, again via `safe_join`. Mercury card-within-card - The "Add another Mercury connection" form was wrapped in a `<details>` `bg-container shadow-border-xs rounded-xl` card. In the Connect drawer (always 0 existing connections), that wrapping card-inside-the-drawer-card has no value — the form is the only thing on the surface. Drop the wrapper when no connections exist; keep the heading + form inline. When 1+ connections exist (the section page) the heading hints "+ Add another connection" without the disclosure indirection. Trade-off: the error-alert blocks lose their `line-clamp-3` / `title=` truncation. Acceptable for now — DS::Alert can grow a truncate option as a follow-up if needed. Open follow-up: DS::Alert itself uses raw Tailwind palette (`bg-yellow-50` etc.) instead of semantic tokens, and only accepts a single string `message:`. A separate issue tracks this. * fix(settings/providers): hoist warning alerts to top of drawer DS::Alert convention across the rest of the app: alerts sit at the top of the form / page / section, not floating between content blocks. The Binance no-withdraw warning and SnapTrade free-tier warning were rendering between the setup-instructions list and the form fields — visually wonky. Move both to the top of their respective panels so the warning is the first thing the user sees when the connect drawer opens. Existing precedents this aligns with: - accounts/_form.html.erb (error alert above form) - valuations/new.html.erb (error alert above form) - other_assets/new.html.erb (info alert above form) - holdings/show.html.erb (warn alerts above content) * fix(DS::Alert): align icon to cap-height of first text line `items-start` on the container made the icon's top edge flush with the text's top edge, leaving the icon's optical center sitting below the text's first-line center. The hand-rolled alerts elsewhere in the codebase (api_keys/new, hostings/_sync_settings, holdings/show) all add `mt-0.5` to the icon for the same reason — fold that into the primitive so every caller gets the cap-height alignment. * copy(settings/providers): tighten alert messaging per voice review Copy expert pass on the new provider drawer alerts. House style: sentence case for titles, lead with the action, drop "Warning:" / "Please" filler (the alert variant icon already signals tone), prefer one short sentence + optional title-paragraph for emphasis. - Binance no-withdraw warning: was a single line "Warning: do NOT enable withdrawal permissions" — alarmist without context. Now splits into "Read-only key only" (title) + "Don't enable withdrawal permissions when creating your Binance API key — Sure only needs read access." (body). - SnapTrade free-tier note: "Free tier includes 5 brokerage connections. Additional connections require a paid SnapTrade plan." → "SnapTrade's free tier covers 5 brokerage connections. Upgrade on SnapTrade for more." - SnapTrade connection-limit-info inside the brokerage list: cut entirely. The drawer already shows the cap; restating it in the list was noise. - SnapTrade needs-registration: "Credentials saved — finish registration to connect a brokerage." → "Credentials saved. Finish setup to connect a brokerage." ("registration" was ambiguous — register where, with whom?) - Enable Banking "Configuration locked" body: "Credentials cannot be changed while you have active bank connections. Remove all connections first to update credentials." → "Disconnect all linked banks before changing these credentials." Same meaning, half the words. - Encryption-error block: title-cased "Encryption Configuration Required" → "Encryption keys missing"; body strips "Please ensure" filler and the parenthetical credential dump, leaving the three credential names inline as a clean list. Self-hosters still get exactly the names they need to set. * feat(settings/providers): SetupSteps partial for connect-drawer instructions Per the design's drawer-cleanup follow-up. Replaces the per-panel "Setup instructions:" + ordered list + "Field descriptions:" block with a shared boxed-step component. The new partial — `_setup_steps.html.erb` — takes a `steps:` array of strings (or html_safe strings for inline links / code) plus an optional `help:` hash for a docs link below the steps. The eyebrow label is "Setup" (uppercase, tracking-wider) matching Sure's other section labels. Applied across all eleven provider panels: - _provider_form (Plaid + Plaid EU): field descriptions move to per-field helper text below the input. - _binance, _coinbase, _coinstats, _indexa_capital, _lunchflow, _mercury, _simplefin, _snaptrade, _sophtron, _enable_banking: ordered list + duplicate "Field descriptions" block both replaced by the partial. - Some panels' inline copy tightened in the same pass (Lunch Flow, SimpleFIN, Enable Banking) — the design copy is shorter than the current legacy strings; a copy-pass through every panel can follow as a separate cleanup. Token notes: uses scale tokens (`rounded-xl`, `text-xs`/`text-sm`, `tracking-wider`) instead of the design mock's exact arbitrary values, per the consistency-over-design-specs directive on this branch. * fix(settings/providers): tighten panel spacing + relocate per-panel notes Read-flow audit on each connect drawer. The uniform `space-y-4` treated every block (alert, steps, info card, fields, button) the same — visually they were five sibling boxes with no grouping. The fix is per panel; some notes belong as helper text on a specific field, others as a tightly-grouped pre-fill primer. Per panel: - Binance: IP-whitelisting card now matches the setup_steps box (`bg-surface-inset rounded-xl`) and is wrapped with setup_steps in an inner `space-y-2` so they read as a single pre-fill primer cluster. Same eyebrow treatment ("IP whitelisting required") so the two boxes look like sister panels, not unrelated chrome. - SnapTrade: drop the description paragraph above setup_steps. The available-providers card grid already markets SnapTrade ("Connect brokerage accounts via the SnapTrade aggregation network."); repeating in the drawer was duplication. - Mercury: move the sandbox-API note out of its standalone <p> below setup_steps and into per-field helper text under the base_url field — the user only cares about the sandbox URL when they're filling that field. Applied to both the per-item edit form and the add-new form. - _setup_steps partial: drop the now-pointless `mb-2` (outer `space-y-4` already controls the gap; bottom-margin was dead CSS thanks to margin-collapse rules with the next sibling's margin-top). * fix(settings/providers): plaid + indexa drawers join the SetupSteps look Two unifying fixes after the panel-by-panel screenshots showed mixed treatments. Plaid + Plaid EU - The registry-driven panel (_provider_form) was still rendering each adapter's markdown `description` block as plain prose ("Setup instructions: 1. Visit the Plaid Dashboard ..."). Other panels switched to the SetupSteps box; Plaid was the odd one out. - Drop the markdown `description` block from both plaid_adapter and plaid_eu_adapter. Render setup_steps in _provider_form for these two provider keys via inline ERB (link helper handles the Plaid Dashboard link cleanly; the regional differences fold to the same dashboard URL with a different account scope). - Other registry-based providers fall through to the previous markdown description path — no behavior change for them. Indexa Capital - The API token field was wrapped in a `bg-surface border` "card" that duplicated the field label inside as a heading and put the description above the input. Same pattern the user flagged as the "card within input" anti-shape. - Drop the wrapper. The styled-form input renders its own label; description moves to per-field helper text below the input, matching the pattern used by Plaid (provider_form) and Mercury. * fix(settings/providers): surface configured plaid_eu + dedup show context provider_summary had no plaid_eu branch — configured plaid_eu was falling through to status :off and rendering in Available even with credentials set. Collapse plaid + plaid_eu into a single registry check. Drawer title for non-panel configurations was provider_key.titleize, which produced "Plaid Eu" while the available card grid used metadata[:name] = "Plaid EU". Read from metadata first. While here: - compute_provider_sync_health no longer relies on instance_variable_get; pass family_panel_items explicitly so the hash-key/ivar-name coupling is gone. - drop unused .includes(:syncs, :mercury_accounts) and .includes(:snaptrade_accounts) from prepare_show_context. The show view only consults summary[:status]; the eager-loads were carried over from connect_form (which has its own load_provider_items). * i18n(settings/providers): localize plaid setup steps + drop dead defaults The plaid + plaid_eu setup steps in _provider_form.html.erb were hardcoded English strings. Move them to settings.providers.plaid_panel (shared) + plaid_eu_panel (EU-specific step 1) so they can be translated like every other panel. _setup_steps.html.erb was passing default: "Setup" / "Need help?" to t(), masking missing translations in non-EN locales. Both keys exist in en.yml — drop the defaults so missing translations actually surface. * test(settings/providers): cover plaid_eu, clear filters, warn outline Three system test additions: - Configured plaid_eu surfaces in Your connections (regression guard for the helper fix; previously fell through to Available). - Clear filters button resets input + chip state and brings cards back into view. - :warn-state connection row carries the border-warning/25 outline that distinguishes it from an :ok row. * copy(settings/providers): drop em dashes, naturalize phrasing Sweep through every string this branch added and replace em-dash splices with full sentences or simple connectives. en.yml: - drawer_trust_statement now reads "Read-only access. Sure can never move money, and your credentials are stored encrypted." instead of em-dash splicing. - sync_all_recently / recently_synced split into two sentences. - binance_panel.no_withdraw_body, plaid_panel.step_1_html / step_2, plaid_eu_panel.step_1_html same treatment. Hardcoded panel steps (enable_banking, lunchflow, simplefin) become "Go to <link> and …" or "Go to <link> for …" instead of the "<link> — get …" splice. Same setup_steps comment cleaned up. * fix(settings/providers): address CodeRabbit pass on PR #1717 Fixed: - Localize the setup steps in _enable_banking_panel, _lunchflow_panel, and _simplefin_panel. The em-dash sweep had rewritten these into hardcoded English; they now route through settings.providers.{enable_banking,lunchflow,simplefin}_panel step_1_html / step_2 / step_3 keys, mirroring the plaid_panel treatment. - connect_form: silent redirect when provider_key is unknown now carries an alert (settings.providers.not_found) so misrouted links don't drop users on the page with no feedback. - sync action: redirect notice now reflects whether anything was actually scheduled — adds settings.providers.sync_provider_no_items for the "all items already syncing or none exist" path. - Family::Syncer test: count plaid_items via the .syncable scope to match what Family::Syncer actually schedules (already done for binance_items in the same test). Skipped, with reasons: - focus:ring-gray-500/-gray-900 in coinstats / coinbase / simplefin / search_filters: tracked under issue #1715 as part of the raw-palette → DS-token sweep across the whole codebase. - Coinbase #0052FF brand-color wrapper: tracked under PR maybe-finance#1710's follow-up tracking comment as the deferred Provider::Metadata colour-palette decision (designer §01). - Sophtron submit-button extraction into DS::Button: same deferred sweep — every panel hand-rolls this class string; one-off extraction would just churn. - Redundant .html_safe on _html keys in coinstats: tracked in #1715. - _provider_form.html.erb env hint, "Optional" placeholder, "Save and connect" submit: pre-existing strings not added on this branch. - Renaming sync_health_for's :stale to :data_stale: pre-existing shape, refactor scope. - Plaid_eu using plaid_panel.step_2/step_3 keys: deliberate. Same English copy across both providers; duplicating keys would just give translators twice the work for identical strings. - _enable_banking_panel / _lunchflow_panel / _simplefin_panel alert + submit + button labels: pre-existing hardcoded strings from before this branch. Setup steps were the strings actually touched in the em-dash sweep, so those got localized; the rest belong in a broader panel-i18n pass. Verified: - bundle exec erb_lint on the three panels: clean. - bin/rubocop on controller + test: clean. - bin/rails test test/models/family/syncer_test.rb test/controllers/settings/providers_controller_test.rb: 23 runs, 85 assertions, 0 failures. - DISABLE_PARALLELIZATION=true bin/rails test test/system/settings/providers_test.rb: 15 runs, 38 assertions, 0 failures. * fix(db): rename migration to clear collision with main's 20260508120000 Main's PR maybe-finance#1705 (Sophtron manual sync) shipped a migration with the same 20260508120000 timestamp as our add_last_sync_all_attempted_at_to_families migration. The merge that brought main into this branch left both files at the same prefix, which trips Rails' "Duplicate migration" guard at db:schema:load time and broke CI. Renaming our migration to 20260510120000 keeps the column it adds intact (already in db/schema.rb) and bumps the schema version to match. No DB-level change. * fix(settings/providers): card + strip a11y polish - Bring back the slim health strip; gate behind 10+ accounts (HEALTH_STRIP_MIN_ACCOUNTS) so it stays out of the way for small libraries where per-row pills already carry the signal. - Status pill: drop the bg-{c}/10 text-{c} pattern (failed AA on warn / err); switch to bg-surface-inset text-primary with the dot still carrying semantic colour. Passes AA in both themes; the dot is the only colourful affordance. - Maturity badge: bg-alpha-black-50 was invisible against the hovered card bg in light mode and against bg-container in dark mode. Move to bg-surface-inset + border-tertiary so it stays delineated through hover and dark theme. - Provider card: keep the bg shift on hover (now bg-surface-inset for a perceptible delta), focus ring promoted alpha-black-100 -> alpha-black-300 (visible to keyboard users), meta line text-subdued -> text-secondary (text-subdued failed AA at 2.86:1 against bg-container). - Restore the per-provider logo palette dropped in 6abceb0. Yellow-on-white was the BLOCKER then; bumped Binance to yellow-600 and CoinStats to pink-600 (distinct from Binance and AA-safe with white text). - Health strip dividers: bg-alpha-black-100 was invisible in dark mode. Switch to border-l border-secondary so the DS variant flips correctly. * fix(settings/providers): keep row height on open The right-side meta + status pill + sync button group is hidden via group-open:hidden, but the sync button (DS::Button size sm, h-8) is what dictated the row's natural height. With it gone, the row collapsed from 60px to 48px and the title appeared to jump upward. Pin a min-h-15 on the <summary> so the height stays constant through open/close. * Let's not regress IPv6 * Keep the only real change in schema.rb --------- Signed-off-by: Juan José Mata <[email protected]> Signed-off-by: Guillem Arias Fauste <[email protected]> Co-authored-by: Claude <[email protected]> Co-authored-by: Guillem Arias <[email protected]> Co-authored-by: Guillem Arias Fauste <[email protected]>
Summary
Feature Overview
This PR adds Brex as a new read-only financial provider in Sure, following the same post-Mercury multi-connection pattern already used for Mercury.
Users can add one or more Brex API token connections, discover available Brex cash/card accounts, select which accounts to link, and sync Brex balances and transactions into Sure. Cash accounts are imported as depository accounts, while Brex card activity is synced into an aggregate credit card account per Brex connection.
The implementation includes per-connection account discovery, explicit connection selection when multiple Brex credentials exist, scoped duplicate upstream account IDs, per-item cache isolation, credential preservation on blank edits, token stripping/rejection rules, and a Brex API URL allowlist.
This is intentionally read-only and does not add any public API/OpenAPI changes.
What changed
Why
Validation
109 runs, 389 assertions.bin/rails zeitwerk:checkpassed.bin/rubocoppassed.bin/brakeman --no-pagerpassed.git diff --checkpassed.bin/rails testcompleted with3599 runs, 14666 assertions, 0 failures, 1 errors, 23 skips; the single error is the known unrelated exact-devcontainerlibvips.so.42image-processing failure.app/controllers/api/v1,spec/requests/api/v1, ordocs/api/openapi.yamlchanges were made, so rswag/OpenAPI regeneration was not run.Screenshots
Screenshots were captured with headless Chromium. Local artifact links for manual attachment/review:
Provider settings
Expanded Brex provider panel
Edit Brex connection
Add Brex connection
Explicit connection required
Accounts index with Brex groups
Brex setup accounts modal
New account provider choice
Notes
1.0, while the supported account/transaction paths used here are/v2/...; this PR does not add user-selectable API versions or arbitrary endpoint paths.Summary by CodeRabbit
New Features
Documentation / Localization
Style